Files
backend/src/profile-fields/profile-fields.service.ts

327 lines
9.1 KiB
TypeScript

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 raw data - TransformInterceptor will wrap it
return { 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<string, {
slug: string;
name: string;
fieldType: string;
options: { label: string; value: string }[];
}>();
// Map normalized names to their primary slug for de-duplication
const nameToSlugMap = new Map<string, string>();
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());
}
}