This commit is contained in:
pradeepkumar
2026-01-24 03:33:54 +05:30
parent 3df8ea067a
commit 710803a3ec
14 changed files with 1099 additions and 1 deletions

View File

@@ -33,6 +33,20 @@ enum AuthProvider {
TWITTER
}
enum FieldType {
TEXT // Single line text input
TEXTAREA // Multi-line text area
CHECKBOX // Single checkbox (Yes/No)
CHECKBOX_GROUP // Multiple checkboxes in grid (select multiple)
RADIO // Radio buttons (select one)
SELECT // Dropdown (select one)
MULTI_SELECT // Dropdown with multi-select
RANGE // Slider with min/max
NUMBER // Number input
DATE // Date picker
TAG_INPUT // Tag input (add custom tags)
}
// ===========================================
// USER - Authentication Only
@@ -160,6 +174,7 @@ model AgentProfile {
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
agentType AgentType? @relation(fields: [agentTypeId], references: [id])
fieldValues AgentProfileFieldValue[]
@@index([userId])
@@index([agentTypeId])
@@ -188,12 +203,130 @@ model AgentType {
updatedAt DateTime @updatedAt
// Relations
agents AgentProfile[]
agents AgentProfile[]
agentTypeSections AgentTypeSection[]
@@index([isActive])
@@map("agent_types")
}
// ===========================================
// DYNAMIC PROFILE FIELD SYSTEM
// ===========================================
// Profile Sections - Groups of related fields
model ProfileSection {
id String @id @default(uuid())
name String // e.g., "Location", "Experience", "Specialization"
slug String @unique // URL-friendly identifier
description String?
icon String? // Icon name or URL
sortOrder Int @default(0)
isActive Boolean @default(true)
isGlobal Boolean @default(false) // If true, applies to ALL agent types
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
fields ProfileField[]
agentTypeSections AgentTypeSection[]
@@index([isActive])
@@index([sortOrder])
@@map("profile_sections")
}
// Many-to-Many: Which sections belong to which agent types
model AgentTypeSection {
id String @id @default(uuid())
agentTypeId String
sectionId String
sortOrder Int @default(0) // Order specific to this agent type
isRequired Boolean @default(false) // Is this section required for this type
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
agentType AgentType @relation(fields: [agentTypeId], references: [id], onDelete: Cascade)
section ProfileSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
@@unique([agentTypeId, sectionId])
@@index([agentTypeId])
@@index([sectionId])
@@map("agent_type_sections")
}
// Profile Fields - Individual form fields
model ProfileField {
id String @id @default(uuid())
sectionId String
name String // e.g., "State", "Years in Business"
slug String // Unique within section: e.g., "state", "years_in_business"
fieldType FieldType
description String? // Help text shown to user
placeholder String? // Placeholder text for input
defaultValue String? // Default value (JSON for complex types)
sortOrder Int @default(0)
isActive Boolean @default(true)
isRequired Boolean @default(false)
// Validation rules (JSON)
validation Json? // { min, max, minLength, maxLength, pattern, etc. }
// For SELECT, RADIO, MULTI_SELECT, CHECKBOX_GROUP
options Json? // [{ value: "...", label: "...", sortOrder: 0 }]
// For RANGE/slider
rangeConfig Json? // { min: 0, max: 100, step: 1 }
// UI Configuration
uiConfig Json? // { columns: 2, showInPreview: true, etc. }
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
section ProfileSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
fieldValues AgentProfileFieldValue[]
@@unique([sectionId, slug])
@@index([sectionId])
@@index([isActive])
@@map("profile_fields")
}
// Agent Profile Field Values - Stores actual agent data
model AgentProfileFieldValue {
id String @id @default(uuid())
agentProfileId String
fieldId String
// Value storage - use appropriate column based on field type
textValue String? @db.Text // For TEXT, TEXTAREA
numberValue Float? // For NUMBER, RANGE
booleanValue Boolean? // For single CHECKBOX
jsonValue Json? // For MULTI_SELECT, RADIO, complex data
dateValue DateTime? // For DATE
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
agentProfile AgentProfile @relation(fields: [agentProfileId], references: [id], onDelete: Cascade)
field ProfileField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
@@unique([agentProfileId, fieldId])
@@index([agentProfileId])
@@index([fieldId])
@@map("agent_profile_field_values")
}
// ===========================================
// SESSION - JWT Token Management
// ===========================================

View File

@@ -13,6 +13,7 @@ import { EmailModule } from './email';
import { AgentTypesModule } from './agent-types';
import { AgentsModule } from './agents';
import { UploadModule } from './upload';
import { ProfileFieldsModule } from './profile-fields';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@@ -49,6 +50,7 @@ import { AppService } from './app.service';
AgentTypesModule,
AgentsModule,
UploadModule,
ProfileFieldsModule,
],
controllers: [AppController],
providers: [

View File

@@ -0,0 +1,30 @@
import { IsUUID, IsOptional, IsBoolean, IsInt } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AssignSectionDto {
@ApiProperty({ description: 'Agent Type ID to assign this section to' })
@IsUUID()
agentTypeId: string;
@ApiPropertyOptional({ description: 'Sort order for this agent type', default: 0 })
@IsOptional()
@IsInt()
sortOrder?: number;
@ApiPropertyOptional({ description: 'Whether this section is required for this agent type', default: false })
@IsOptional()
@IsBoolean()
isRequired?: boolean;
}
export class UpdateAssignmentDto {
@ApiPropertyOptional({ description: 'Sort order for this agent type' })
@IsOptional()
@IsInt()
sortOrder?: number;
@ApiPropertyOptional({ description: 'Whether this section is required for this agent type' })
@IsOptional()
@IsBoolean()
isRequired?: boolean;
}

View File

@@ -0,0 +1,113 @@
import {
IsString,
IsOptional,
IsBoolean,
IsInt,
IsUUID,
IsEnum,
IsObject,
IsArray,
MinLength,
MaxLength,
ValidateNested,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { FieldType } from '@prisma/client';
export class FieldOptionDto {
@ApiProperty({ description: 'Option value' })
@IsString()
value: string;
@ApiProperty({ description: 'Option display label' })
@IsString()
label: string;
@ApiPropertyOptional({ description: 'Sort order for this option' })
@IsOptional()
@IsInt()
sortOrder?: number;
}
export class RangeConfigDto {
@ApiProperty({ description: 'Minimum value' })
min: number;
@ApiProperty({ description: 'Maximum value' })
max: number;
@ApiPropertyOptional({ description: 'Step increment', default: 1 })
@IsOptional()
step?: number;
}
export class CreateFieldDto {
@ApiProperty({ description: 'Section ID this field belongs to' })
@IsUUID()
sectionId: string;
@ApiProperty({ description: 'Field name', example: 'Years in Business' })
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@ApiProperty({ description: 'Field type', enum: FieldType })
@IsEnum(FieldType)
fieldType: FieldType;
@ApiPropertyOptional({ description: 'Help text for the field' })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({ description: 'Placeholder text' })
@IsOptional()
@IsString()
@MaxLength(200)
placeholder?: string;
@ApiPropertyOptional({ description: 'Default value (JSON string for complex types)' })
@IsOptional()
@IsString()
defaultValue?: string;
@ApiPropertyOptional({ description: 'Sort order within the section', default: 0 })
@IsOptional()
@IsInt()
sortOrder?: number;
@ApiPropertyOptional({ description: 'Whether this field is active', default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ description: 'Whether this field is required', default: false })
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@ApiPropertyOptional({ description: 'Validation rules (JSON object)' })
@IsOptional()
@IsObject()
validation?: Record<string, any>;
@ApiPropertyOptional({ description: 'Options for SELECT, RADIO, MULTI_SELECT, CHECKBOX_GROUP', type: [FieldOptionDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FieldOptionDto)
options?: FieldOptionDto[];
@ApiPropertyOptional({ description: 'Configuration for RANGE fields', type: RangeConfigDto })
@IsOptional()
@IsObject()
rangeConfig?: RangeConfigDto;
@ApiPropertyOptional({ description: 'UI configuration (JSON object)' })
@IsOptional()
@IsObject()
uiConfig?: Record<string, any>;
}

View File

@@ -0,0 +1,36 @@
import { IsString, IsOptional, IsBoolean, IsInt, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateSectionDto {
@ApiProperty({ description: 'Section name', example: 'Location' })
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@ApiPropertyOptional({ description: 'Section description' })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({ description: 'Icon URL or icon name' })
@IsOptional()
@IsString()
icon?: string;
@ApiPropertyOptional({ description: 'Sort order for display', default: 0 })
@IsOptional()
@IsInt()
sortOrder?: number;
@ApiPropertyOptional({ description: 'Whether this section is active', default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ description: 'Whether this section applies to all agent types', default: false })
@IsOptional()
@IsBoolean()
isGlobal?: boolean;
}

View File

@@ -0,0 +1,5 @@
export * from './create-section.dto';
export * from './update-section.dto';
export * from './create-field.dto';
export * from './update-field.dto';
export * from './assign-section.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType, OmitType } from '@nestjs/swagger';
import { CreateFieldDto } from './create-field.dto';
export class UpdateFieldDto extends PartialType(OmitType(CreateFieldDto, ['sectionId'] as const)) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateSectionDto } from './create-section.dto';
export class UpdateSectionDto extends PartialType(CreateSectionDto) {}

View File

@@ -0,0 +1,4 @@
export * from './profile-fields.module';
export * from './profile-sections.service';
export * from './profile-fields.service';
export * from './dto';

View File

@@ -0,0 +1,99 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ProfileFieldsService } from './profile-fields.service';
import { CreateFieldDto, UpdateFieldDto } from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { UserRole } from '@prisma/client';
@ApiTags('Profile Fields')
@Controller('profile-fields')
export class ProfileFieldsController {
constructor(private readonly fieldsService: ProfileFieldsService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new profile field (Admin only)' })
create(@Body() createFieldDto: CreateFieldDto) {
return this.fieldsService.create(createFieldDto);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all profile fields (Admin only)' })
@ApiQuery({ name: 'sectionId', required: false, type: String })
@ApiQuery({ name: 'includeInactive', required: false, type: Boolean })
findAll(
@Query('sectionId') sectionId?: string,
@Query('includeInactive') includeInactive?: string,
) {
return this.fieldsService.findAll(sectionId, includeInactive === 'true');
}
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get a profile field by ID (Admin only)' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.fieldsService.findOne(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a profile field (Admin only)' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateFieldDto: UpdateFieldDto,
) {
return this.fieldsService.update(id, updateFieldDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a profile field (Admin only)' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.fieldsService.remove(id);
}
@Patch('section/:sectionId/reorder')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Reorder fields within a section (Admin only)' })
reorderFields(
@Param('sectionId', ParseUUIDPipe) sectionId: string,
@Body() body: { fieldIds: string[] },
) {
return this.fieldsService.reorderFields(sectionId, body.fieldIds);
}
@Patch('bulk/order')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Bulk update field order (Admin only)' })
bulkUpdateOrder(@Body() body: { updates: { id: string; sortOrder: number }[] }) {
return this.fieldsService.bulkUpdateOrder(body.updates);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ProfileSectionsController } from './profile-sections.controller';
import { ProfileSectionsService } from './profile-sections.service';
import { ProfileFieldsController } from './profile-fields.controller';
import { ProfileFieldsService } from './profile-fields.service';
import { PrismaModule } from '../prisma';
@Module({
imports: [PrismaModule],
controllers: [ProfileSectionsController, ProfileFieldsController],
providers: [ProfileSectionsService, ProfileFieldsService],
exports: [ProfileSectionsService, ProfileFieldsService],
})
export class ProfileFieldsModule {}

View File

@@ -0,0 +1,233 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma';
import { CreateFieldDto, UpdateFieldDto } from './dto';
@Injectable()
export class ProfileFieldsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Generate a URL-friendly slug from the name
*/
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/(^_|_$)/g, '');
}
/**
* Create a new profile field
*/
async create(createFieldDto: CreateFieldDto) {
const { sectionId, options, rangeConfig, validation, uiConfig, ...rest } = createFieldDto;
// Verify section exists
const section = await this.prisma.profileSection.findUnique({
where: { id: sectionId },
});
if (!section) {
throw new NotFoundException(`Section with ID ${sectionId} not found`);
}
// Generate slug
const slug = this.generateSlug(createFieldDto.name);
// Check if slug is unique within section
const existing = await this.prisma.profileField.findUnique({
where: {
sectionId_slug: {
sectionId,
slug,
},
},
});
if (existing) {
throw new ConflictException('A field with this name already exists in this section');
}
// Validate options for certain field types
const requiresOptions = ['SELECT', 'RADIO', 'MULTI_SELECT', 'CHECKBOX'];
if (requiresOptions.includes(createFieldDto.fieldType) && (!options || options.length === 0)) {
// CHECKBOX can be a single toggle without options
if (createFieldDto.fieldType !== 'CHECKBOX') {
throw new BadRequestException(`Field type ${createFieldDto.fieldType} requires options`);
}
}
// Validate range config for RANGE type
if (createFieldDto.fieldType === 'RANGE' && !rangeConfig) {
throw new BadRequestException('RANGE field type requires rangeConfig');
}
return this.prisma.profileField.create({
data: {
...rest,
sectionId,
slug,
options: options ? (options as unknown as Prisma.InputJsonValue) : undefined,
rangeConfig: rangeConfig ? (rangeConfig as unknown as Prisma.InputJsonValue) : undefined,
validation: validation ? (validation as unknown as Prisma.InputJsonValue) : undefined,
uiConfig: uiConfig ? (uiConfig as unknown as Prisma.InputJsonValue) : undefined,
},
include: {
section: {
select: { id: true, name: true, slug: true },
},
},
});
}
/**
* Get all fields, optionally filtered by section
*/
async findAll(sectionId?: string, includeInactive = false) {
const where: any = {};
if (sectionId) {
where.sectionId = sectionId;
}
if (!includeInactive) {
where.isActive = true;
}
return this.prisma.profileField.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
include: {
section: {
select: { id: true, name: true, slug: true },
},
},
});
}
/**
* Get a single field by ID
*/
async findOne(id: string) {
const field = await this.prisma.profileField.findUnique({
where: { id },
include: {
section: {
select: { id: true, name: true, slug: true },
},
},
});
if (!field) {
throw new NotFoundException(`Field with ID ${id} not found`);
}
return field;
}
/**
* Update a field
*/
async update(id: string, updateFieldDto: UpdateFieldDto) {
const field = await this.findOne(id);
const data: any = { ...updateFieldDto };
// If name is being updated, update the slug too
if (updateFieldDto.name) {
const newSlug = this.generateSlug(updateFieldDto.name);
// Check if new slug conflicts within the section
const existing = await this.prisma.profileField.findFirst({
where: {
AND: [
{ id: { not: id } },
{ sectionId: field.sectionId },
{ slug: newSlug },
],
},
});
if (existing) {
throw new ConflictException('A field with this name already exists in this section');
}
data.slug = newSlug;
}
return this.prisma.profileField.update({
where: { id },
data,
include: {
section: {
select: { id: true, name: true, slug: true },
},
},
});
}
/**
* Delete a field
*/
async remove(id: string) {
await this.findOne(id);
// Check if any agents have values for this field
const valuesCount = await this.prisma.agentProfileFieldValue.count({
where: { fieldId: id },
});
if (valuesCount > 0) {
throw new ConflictException(
`Cannot delete field. ${valuesCount} agent(s) have values for this field. Deactivate the field instead.`,
);
}
return this.prisma.profileField.delete({
where: { id },
});
}
/**
* Reorder fields within a section
*/
async reorderFields(sectionId: string, fieldIds: string[]) {
// Verify section exists
const section = await this.prisma.profileSection.findUnique({
where: { id: sectionId },
});
if (!section) {
throw new NotFoundException(`Section with ID ${sectionId} not found`);
}
// Update sort order for each field
const updates = fieldIds.map((fieldId, index) =>
this.prisma.profileField.update({
where: { id: fieldId },
data: { sortOrder: index },
}),
);
await this.prisma.$transaction(updates);
return this.findAll(sectionId);
}
/**
* Bulk update fields (for drag-drop reordering)
*/
async bulkUpdateOrder(updates: { id: string; sortOrder: number }[]) {
const operations = updates.map(({ id, sortOrder }) =>
this.prisma.profileField.update({
where: { id },
data: { sortOrder },
}),
);
await this.prisma.$transaction(operations);
return { success: true, updated: updates.length };
}
}

View File

@@ -0,0 +1,111 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ProfileSectionsService } from './profile-sections.service';
import { CreateSectionDto, UpdateSectionDto, AssignSectionDto, UpdateAssignmentDto } from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { UserRole } from '@prisma/client';
@ApiTags('Profile Sections')
@Controller('profile-sections')
export class ProfileSectionsController {
constructor(private readonly sectionsService: ProfileSectionsService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new profile section (Admin only)' })
create(@Body() createSectionDto: CreateSectionDto) {
return this.sectionsService.create(createSectionDto);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all profile sections (Admin only)' })
@ApiQuery({ name: 'includeInactive', required: false, type: Boolean })
findAll(@Query('includeInactive') includeInactive?: string) {
return this.sectionsService.findAll(includeInactive === 'true');
}
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get a profile section by ID with fields (Admin only)' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.sectionsService.findOne(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a profile section (Admin only)' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateSectionDto: UpdateSectionDto,
) {
return this.sectionsService.update(id, updateSectionDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete a profile section (Admin only)' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.sectionsService.remove(id);
}
@Post(':id/assign')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Assign section to an agent type (Admin only)' })
assignToAgentType(
@Param('id', ParseUUIDPipe) id: string,
@Body() assignDto: AssignSectionDto,
) {
return this.sectionsService.assignToAgentType(id, assignDto);
}
@Patch(':id/assignment/:agentTypeId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update section assignment (Admin only)' })
updateAssignment(
@Param('id', ParseUUIDPipe) id: string,
@Param('agentTypeId', ParseUUIDPipe) agentTypeId: string,
@Body() updateDto: UpdateAssignmentDto,
) {
return this.sectionsService.updateAssignment(id, agentTypeId, updateDto);
}
@Delete(':id/assignment/:agentTypeId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth()
@ApiOperation({ summary: 'Remove section from an agent type (Admin only)' })
removeFromAgentType(
@Param('id', ParseUUIDPipe) id: string,
@Param('agentTypeId', ParseUUIDPipe) agentTypeId: string,
) {
return this.sectionsService.removeFromAgentType(id, agentTypeId);
}
}

View File

@@ -0,0 +1,310 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma';
import { CreateSectionDto, UpdateSectionDto, AssignSectionDto, UpdateAssignmentDto } from './dto';
@Injectable()
export class ProfileSectionsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Generate a URL-friendly slug from the name
*/
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
/**
* Create a new profile section
*/
async create(createSectionDto: CreateSectionDto) {
const slug = this.generateSlug(createSectionDto.name);
// Check if slug already exists
const existing = await this.prisma.profileSection.findUnique({
where: { slug },
});
if (existing) {
throw new ConflictException('A section with this name already exists');
}
return this.prisma.profileSection.create({
data: {
...createSectionDto,
slug,
},
include: {
_count: {
select: { fields: true, agentTypeSections: true },
},
},
});
}
/**
* Get all profile sections
*/
async findAll(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
return this.prisma.profileSection.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
include: {
_count: {
select: { fields: true, agentTypeSections: true },
},
agentTypeSections: {
include: {
agentType: {
select: { id: true, name: true },
},
},
},
},
});
}
/**
* Get a single section by ID with all fields
*/
async findOne(id: string) {
const section = await this.prisma.profileSection.findUnique({
where: { id },
include: {
fields: {
orderBy: { sortOrder: 'asc' },
},
agentTypeSections: {
include: {
agentType: {
select: { id: true, name: true },
},
},
orderBy: { sortOrder: 'asc' },
},
_count: {
select: { fields: true, agentTypeSections: true },
},
},
});
if (!section) {
throw new NotFoundException(`Section with ID ${id} not found`);
}
return section;
}
/**
* Update a section
*/
async update(id: string, updateSectionDto: UpdateSectionDto) {
// Check if section exists
await this.findOne(id);
const data: any = { ...updateSectionDto };
// If name is being updated, update the slug too
if (updateSectionDto.name) {
const newSlug = this.generateSlug(updateSectionDto.name);
// Check if new slug conflicts with another section
const existing = await this.prisma.profileSection.findFirst({
where: {
AND: [{ id: { not: id } }, { slug: newSlug }],
},
});
if (existing) {
throw new ConflictException('A section with this name already exists');
}
data.slug = newSlug;
}
return this.prisma.profileSection.update({
where: { id },
data,
include: {
_count: {
select: { fields: true, agentTypeSections: true },
},
},
});
}
/**
* Delete a section
*/
async remove(id: string) {
const section = await this.findOne(id);
// Check if section has any fields
if (section._count.fields > 0) {
throw new ConflictException(
`Cannot delete section. It has ${section._count.fields} field(s). Delete the fields first.`,
);
}
return this.prisma.profileSection.delete({
where: { id },
});
}
/**
* Assign a section to an agent type
*/
async assignToAgentType(sectionId: string, assignDto: AssignSectionDto) {
// Verify section exists
await this.findOne(sectionId);
// Verify agent type exists
const agentType = await this.prisma.agentType.findUnique({
where: { id: assignDto.agentTypeId },
});
if (!agentType) {
throw new NotFoundException(`Agent type with ID ${assignDto.agentTypeId} not found`);
}
// Check if already assigned
const existing = await this.prisma.agentTypeSection.findUnique({
where: {
agentTypeId_sectionId: {
agentTypeId: assignDto.agentTypeId,
sectionId,
},
},
});
if (existing) {
throw new ConflictException('This section is already assigned to this agent type');
}
return this.prisma.agentTypeSection.create({
data: {
sectionId,
agentTypeId: assignDto.agentTypeId,
sortOrder: assignDto.sortOrder ?? 0,
isRequired: assignDto.isRequired ?? false,
},
include: {
section: true,
agentType: true,
},
});
}
/**
* Update a section assignment
*/
async updateAssignment(sectionId: string, agentTypeId: string, updateDto: UpdateAssignmentDto) {
const assignment = await this.prisma.agentTypeSection.findUnique({
where: {
agentTypeId_sectionId: {
agentTypeId,
sectionId,
},
},
});
if (!assignment) {
throw new NotFoundException('Section assignment not found');
}
return this.prisma.agentTypeSection.update({
where: { id: assignment.id },
data: updateDto,
include: {
section: true,
agentType: true,
},
});
}
/**
* Remove a section from an agent type
*/
async removeFromAgentType(sectionId: string, agentTypeId: string) {
const assignment = await this.prisma.agentTypeSection.findUnique({
where: {
agentTypeId_sectionId: {
agentTypeId,
sectionId,
},
},
});
if (!assignment) {
throw new NotFoundException('Section assignment not found');
}
return this.prisma.agentTypeSection.delete({
where: { id: assignment.id },
});
}
/**
* Get all sections for a specific agent type
*/
async getSectionsForAgentType(agentTypeId: string, includeFields = true) {
const agentType = await this.prisma.agentType.findUnique({
where: { id: agentTypeId },
});
if (!agentType) {
throw new NotFoundException(`Agent type with ID ${agentTypeId} not found`);
}
// Get assigned sections
const assignments = await this.prisma.agentTypeSection.findMany({
where: { agentTypeId },
orderBy: { sortOrder: 'asc' },
include: {
section: {
include: includeFields
? {
fields: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
}
: undefined,
},
},
});
// Also get global sections
const globalSections = await this.prisma.profileSection.findMany({
where: {
isGlobal: true,
isActive: true,
},
include: includeFields
? {
fields: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
}
: undefined,
orderBy: { sortOrder: 'asc' },
});
return {
agentType,
sections: [
...globalSections.map((s) => ({ ...s, isRequired: false, isGlobal: true })),
...assignments.map((a) => ({
...a.section,
isRequired: a.isRequired,
isGlobal: false,
})),
],
};
}
}