import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { Prisma, FieldType } 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 }; } /** * Get all filterable fields (public endpoint for search filters) * Returns fields with CHECKBOX_GROUP, SELECT, MULTI_SELECT types that have options * De-duplicates by both slug AND name to handle duplicate fields with different slugs */ async getFilterableFields() { const filterableTypes: FieldType[] = [FieldType.CHECKBOX_GROUP, FieldType.SELECT, FieldType.MULTI_SELECT]; const fields = await this.prisma.profileField.findMany({ where: { isActive: true, fieldType: { in: filterableTypes }, options: { not: Prisma.JsonNull }, }, select: { id: true, name: true, slug: true, fieldType: true, options: true, section: { select: { id: true, name: true, slug: true, }, }, }, orderBy: [ { section: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, ], }); // Group fields by slug to merge options from different sections (same field in different agent types) // Also track by normalized name to de-duplicate fields with same name but different slugs const mergedFields = new Map(); // Map normalized names to their primary slug for de-duplication const nameToSlugMap = new Map(); for (const field of fields) { const fieldOptions = (field.options as { label: string; value: string }[]) || []; const normalizedName = field.name.toLowerCase().trim(); // Check if we already have a field with this name (different slug, same name) const existingSlugForName = nameToSlugMap.get(normalizedName); if (existingSlugForName) { // Merge into existing field with same name const existingField = mergedFields.get(existingSlugForName); if (existingField) { const existingValues = new Set(existingField.options.map(o => o.value)); for (const opt of fieldOptions) { if (!existingValues.has(opt.value)) { existingField.options.push(opt); } } } } else { // Check if we have an existing field with same slug const existingFieldBySlug = mergedFields.get(field.slug); if (existingFieldBySlug) { // Merge options, avoiding duplicates by value const existingValues = new Set(existingFieldBySlug.options.map(o => o.value)); for (const opt of fieldOptions) { if (!existingValues.has(opt.value)) { existingFieldBySlug.options.push(opt); } } } else { // New field - add to both maps mergedFields.set(field.slug, { slug: field.slug, name: field.name, fieldType: field.fieldType, options: [...fieldOptions], }); nameToSlugMap.set(normalizedName, field.slug); } } } return Array.from(mergedFields.values()); } }