diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ea4915..bba762c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 // =========================================== diff --git a/src/app.module.ts b/src/app.module.ts index 9cccffe..3835061 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/profile-fields/dto/assign-section.dto.ts b/src/profile-fields/dto/assign-section.dto.ts new file mode 100644 index 0000000..eee10af --- /dev/null +++ b/src/profile-fields/dto/assign-section.dto.ts @@ -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; +} diff --git a/src/profile-fields/dto/create-field.dto.ts b/src/profile-fields/dto/create-field.dto.ts new file mode 100644 index 0000000..bcd98b3 --- /dev/null +++ b/src/profile-fields/dto/create-field.dto.ts @@ -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; + + @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; +} diff --git a/src/profile-fields/dto/create-section.dto.ts b/src/profile-fields/dto/create-section.dto.ts new file mode 100644 index 0000000..5251ff3 --- /dev/null +++ b/src/profile-fields/dto/create-section.dto.ts @@ -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; +} diff --git a/src/profile-fields/dto/index.ts b/src/profile-fields/dto/index.ts new file mode 100644 index 0000000..f079b60 --- /dev/null +++ b/src/profile-fields/dto/index.ts @@ -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'; diff --git a/src/profile-fields/dto/update-field.dto.ts b/src/profile-fields/dto/update-field.dto.ts new file mode 100644 index 0000000..424c1bc --- /dev/null +++ b/src/profile-fields/dto/update-field.dto.ts @@ -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)) {} diff --git a/src/profile-fields/dto/update-section.dto.ts b/src/profile-fields/dto/update-section.dto.ts new file mode 100644 index 0000000..98eda8d --- /dev/null +++ b/src/profile-fields/dto/update-section.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateSectionDto } from './create-section.dto'; + +export class UpdateSectionDto extends PartialType(CreateSectionDto) {} diff --git a/src/profile-fields/index.ts b/src/profile-fields/index.ts new file mode 100644 index 0000000..d17b111 --- /dev/null +++ b/src/profile-fields/index.ts @@ -0,0 +1,4 @@ +export * from './profile-fields.module'; +export * from './profile-sections.service'; +export * from './profile-fields.service'; +export * from './dto'; diff --git a/src/profile-fields/profile-fields.controller.ts b/src/profile-fields/profile-fields.controller.ts new file mode 100644 index 0000000..4b0ddce --- /dev/null +++ b/src/profile-fields/profile-fields.controller.ts @@ -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); + } +} diff --git a/src/profile-fields/profile-fields.module.ts b/src/profile-fields/profile-fields.module.ts new file mode 100644 index 0000000..858b98a --- /dev/null +++ b/src/profile-fields/profile-fields.module.ts @@ -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 {} diff --git a/src/profile-fields/profile-fields.service.ts b/src/profile-fields/profile-fields.service.ts new file mode 100644 index 0000000..6b0ed46 --- /dev/null +++ b/src/profile-fields/profile-fields.service.ts @@ -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 }; + } +} diff --git a/src/profile-fields/profile-sections.controller.ts b/src/profile-fields/profile-sections.controller.ts new file mode 100644 index 0000000..c2757d1 --- /dev/null +++ b/src/profile-fields/profile-sections.controller.ts @@ -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); + } +} diff --git a/src/profile-fields/profile-sections.service.ts b/src/profile-fields/profile-sections.service.ts new file mode 100644 index 0000000..259d6ba --- /dev/null +++ b/src/profile-fields/profile-sections.service.ts @@ -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, + })), + ], + }; + } +}