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 }; } }