234 lines
6.0 KiB
TypeScript
234 lines
6.0 KiB
TypeScript
|
|
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 };
|
||
|
|
}
|
||
|
|
}
|