327 lines
9.1 KiB
TypeScript
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());
|
|
}
|
|
}
|