// ============================================================ // CustomFieldsService — CRUD fuer Definitionen + Werte // ============================================================ import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger, } from '@nestjs/common'; import { Prisma } from '.prisma/crm-client'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { CreateCustomFieldDto, CustomFieldEntityType, CustomFieldType, } from './dto/create-custom-field.dto'; import { UpdateCustomFieldDto } from './dto/update-custom-field.dto'; import { SetCustomFieldValuesDto } from './dto/set-custom-field-values.dto'; // -------------------------------------------------------- // Response-Interface fuer Entity-Detail-Integration // -------------------------------------------------------- export interface CustomFieldEntry { fieldDefId: string; name: string; label: string; fieldType: string; value: string | number | boolean | string[] | null; } @Injectable() export class CustomFieldsService { private readonly logger = new Logger(CustomFieldsService.name); constructor(private readonly prisma: CrmPrismaService) {} // ============================================================ // Slug-Generierung // ============================================================ /** * Generiert einen DB-sicheren Slug aus dem Label. * "Kundennummer" -> "kundennummer" * "Messe / Event" -> "messe-event" * "USt-IdNr." -> "ust-idnr" * Umlaute: ae/oe/ue/ss */ private slugify(label: string): string { return label .toLowerCase() .trim() .replace(/[äÄ]/g, 'ae') .replace(/[öÖ]/g, 'oe') .replace(/[üÜ]/g, 'ue') .replace(/ß/g, 'ss') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/-{2,}/g, '-'); } // ============================================================ // CRUD — Feld-Definitionen // ============================================================ async createDefinition(tenantId: string, dto: CreateCustomFieldDto) { // Options-Validierung this.validateOptionsForFieldType(dto.fieldType, dto.options); const name = this.slugify(dto.label); if (!name) { throw new BadRequestException('Label ergibt keinen gueltigen internen Namen'); } // Uniqueness pruefen const existing = await this.prisma.customFieldDef.findUnique({ where: { tenantId_entityType_name: { tenantId, entityType: dto.entityType, name, }, }, }); if (existing) { throw new ConflictException( `Ein Feld mit dem Namen "${name}" existiert bereits fuer ${dto.entityType}`, ); } return this.prisma.customFieldDef.create({ data: { tenantId, entityType: dto.entityType, name, label: dto.label, fieldType: dto.fieldType, options: dto.options ? (dto.options as unknown as Prisma.InputJsonValue) : undefined, isRequired: dto.isRequired ?? false, position: dto.position ?? 0, }, }); } async findAllDefinitions(tenantId: string, entityType?: CustomFieldEntityType) { const where: Prisma.CustomFieldDefWhereInput = { tenantId }; if (entityType) { where.entityType = entityType; } return this.prisma.customFieldDef.findMany({ where, orderBy: [{ position: 'asc' }, { label: 'asc' }], include: { _count: { select: { values: true } }, }, }); } async findOneDefinition(tenantId: string, id: string) { const def = await this.prisma.customFieldDef.findFirst({ where: { id, tenantId }, include: { _count: { select: { values: true } }, }, }); if (!def) { throw new NotFoundException('Feld-Definition nicht gefunden'); } return def; } async updateDefinition(tenantId: string, id: string, dto: UpdateCustomFieldDto) { const existing = await this.findOneDefinition(tenantId, id); // Options nur fuer DROPDOWN/MULTI_SELECT erlauben if (dto.options !== undefined) { const isSelectType = existing.fieldType === CustomFieldType.DROPDOWN || existing.fieldType === CustomFieldType.MULTI_SELECT; if (!isSelectType) { throw new BadRequestException( 'options darf nur fuer DROPDOWN/MULTI_SELECT Felder gesetzt werden', ); } if (dto.options.length === 0) { throw new BadRequestException( 'options darf nicht leer sein fuer DROPDOWN/MULTI_SELECT', ); } } // Label-Aenderung → Slug neu berechnen + Unique-Check let name: string | undefined; if (dto.label && dto.label !== existing.label) { name = this.slugify(dto.label); if (!name) { throw new BadRequestException('Label ergibt keinen gueltigen internen Namen'); } if (name !== existing.name) { const duplicate = await this.prisma.customFieldDef.findFirst({ where: { tenantId, entityType: existing.entityType, name, NOT: { id }, }, }); if (duplicate) { throw new ConflictException( `Ein Feld mit dem Namen "${name}" existiert bereits fuer ${existing.entityType}`, ); } } } const updateData: Prisma.CustomFieldDefUpdateInput = {}; if (dto.label !== undefined) updateData.label = dto.label; if (name !== undefined) updateData.name = name; if (dto.options !== undefined) updateData.options = dto.options as unknown as Prisma.InputJsonValue; if (dto.isRequired !== undefined) updateData.isRequired = dto.isRequired; if (dto.position !== undefined) updateData.position = dto.position; return this.prisma.customFieldDef.update({ where: { id }, data: updateData, include: { _count: { select: { values: true } }, }, }); } async removeDefinition(tenantId: string, id: string) { await this.findOneDefinition(tenantId, id); // Prisma Cascade loescht automatisch alle zugehoerigen Values return this.prisma.customFieldDef.delete({ where: { id }, }); } // ============================================================ // Werte — Bulk-Upsert + Lesen // ============================================================ async setValues(tenantId: string, entityId: string, dto: SetCustomFieldValuesDto) { if (dto.values.length === 0) { return []; } // 1. Alle referenzierten Definitionen in einer Query laden const fieldDefIds = dto.values.map((v) => v.fieldDefId); const fieldDefs = await this.prisma.customFieldDef.findMany({ where: { id: { in: fieldDefIds }, tenantId, }, }); const defMap = new Map(fieldDefs.map((d) => [d.id, d])); // 2. Validierung: Alle fieldDefIds muessen existieren + Typ-Check for (const item of dto.values) { const def = defMap.get(item.fieldDefId); if (!def) { throw new NotFoundException( `Feld-Definition "${item.fieldDefId}" nicht gefunden`, ); } this.validateValue(def.fieldType as CustomFieldType, def.label, def.options, item.value); } // 3. Upsert in Transaction return this.prisma.$transaction(async (tx) => { const results: Array<{ fieldDefId: string; entityId: string; value: string | number | boolean | string[] | null; }> = []; for (const item of dto.values) { const def = defMap.get(item.fieldDefId)!; if (item.value === null) { // null = Wert loeschen await tx.customFieldValue.deleteMany({ where: { fieldDefId: item.fieldDefId, entityId }, }); results.push({ fieldDefId: item.fieldDefId, entityId, value: null }); } else { const columns = this.mapValueToColumns(def.fieldType as CustomFieldType, item.value); await tx.customFieldValue.upsert({ where: { fieldDefId_entityId: { fieldDefId: item.fieldDefId, entityId, }, }, update: { ...columns, }, create: { tenantId, fieldDefId: item.fieldDefId, entityId, ...columns, }, }); results.push({ fieldDefId: item.fieldDefId, entityId, value: item.value }); } } return results; }); } async getValues(tenantId: string, entityId: string) { const values = await this.prisma.customFieldValue.findMany({ where: { tenantId, entityId }, include: { fieldDef: { select: { id: true, name: true, label: true, fieldType: true, entityType: true, options: true, isRequired: true, position: true, }, }, }, orderBy: { fieldDef: { position: 'asc' } }, }); return values.map((v) => ({ fieldDefId: v.fieldDef.id, name: v.fieldDef.name, label: v.fieldDef.label, fieldType: v.fieldDef.fieldType, isRequired: v.fieldDef.isRequired, value: this.extractValue(v.fieldDef.fieldType as CustomFieldType, v), })); } // ============================================================ // Integration-Helper fuer Entity-Detail-Responses // ============================================================ /** * Liefert ALLE definierten Custom Fields fuer einen Entity-Typ, * zusammen mit den gespeicherten Werten (oder null). * Wird in contacts/companies/deals findOne() aufgerufen. */ async getCustomFieldsForEntity( tenantId: string, entityType: CustomFieldEntityType, entityId: string, ): Promise { // Alle Definitionen fuer diesen Entity-Typ laden const fieldDefs = await this.prisma.customFieldDef.findMany({ where: { tenantId, entityType }, orderBy: { position: 'asc' }, }); if (fieldDefs.length === 0) return []; // Alle Werte fuer diese Entity laden const values = await this.prisma.customFieldValue.findMany({ where: { tenantId, entityId, fieldDefId: { in: fieldDefs.map((d) => d.id) }, }, }); const valueMap = new Map(values.map((v) => [v.fieldDefId, v])); // Merge: ALLE Definitionen mit Wert (oder null) return fieldDefs.map((def) => { const valueRow = valueMap.get(def.id); return { fieldDefId: def.id, name: def.name, label: def.label, fieldType: def.fieldType, value: valueRow ? this.extractValue(def.fieldType as CustomFieldType, valueRow) : null, }; }); } // ============================================================ // Value-Mapping: fieldType → Datenbank-Spalte // ============================================================ private mapValueToColumns( fieldType: CustomFieldType, value: string | number | boolean | string[], ): { valueText: string | null; valueNumber: Prisma.Decimal | number | null; valueDate: Date | null; valueBoolean: boolean | null; valueJson: typeof Prisma.DbNull | Prisma.InputJsonValue; } { const result: { valueText: string | null; valueNumber: Prisma.Decimal | number | null; valueDate: Date | null; valueBoolean: boolean | null; valueJson: typeof Prisma.DbNull | Prisma.InputJsonValue; } = { valueText: null, valueNumber: null, valueDate: null, valueBoolean: null, valueJson: Prisma.DbNull, // Prisma erfordert DbNull statt null fuer JSON-Felder }; switch (fieldType) { case CustomFieldType.TEXT: case CustomFieldType.TEXTAREA: case CustomFieldType.URL: case CustomFieldType.DROPDOWN: result.valueText = String(value); break; case CustomFieldType.NUMBER: result.valueNumber = Number(value); break; case CustomFieldType.DATE: result.valueDate = new Date(value as string); break; case CustomFieldType.CHECKBOX: result.valueBoolean = Boolean(value); break; case CustomFieldType.MULTI_SELECT: result.valueJson = value as Prisma.InputJsonValue; break; } return result; } // ============================================================ // Value-Extraktion: Datenbank-Spalte → JS-Wert // ============================================================ private extractValue( fieldType: CustomFieldType, row: { valueText: string | null; valueNumber: Prisma.Decimal | null; valueDate: Date | null; valueBoolean: boolean | null; valueJson: Prisma.JsonValue; }, ): string | number | boolean | string[] | null { switch (fieldType) { case CustomFieldType.TEXT: case CustomFieldType.TEXTAREA: case CustomFieldType.URL: case CustomFieldType.DROPDOWN: return row.valueText; case CustomFieldType.NUMBER: return row.valueNumber !== null ? Number(row.valueNumber) : null; case CustomFieldType.DATE: return row.valueDate ? row.valueDate.toISOString() : null; case CustomFieldType.CHECKBOX: return row.valueBoolean; case CustomFieldType.MULTI_SELECT: return (row.valueJson as string[] | null) ?? null; default: return row.valueText; } } // ============================================================ // Validierung // ============================================================ /** * Prueft ob options fuer den gegebenen Feldtyp korrekt sind. */ private validateOptionsForFieldType( fieldType: CustomFieldType, options?: Array<{ value: string; label: string }>, ): void { const isSelectType = fieldType === CustomFieldType.DROPDOWN || fieldType === CustomFieldType.MULTI_SELECT; if (isSelectType && (!options || options.length === 0)) { throw new BadRequestException( 'options ist Pflicht fuer DROPDOWN und MULTI_SELECT Felder', ); } if (!isSelectType && options && options.length > 0) { throw new BadRequestException( 'options darf nur fuer DROPDOWN/MULTI_SELECT gesetzt werden', ); } } /** * Prueft ob ein Wert zum Feldtyp passt. */ private validateValue( fieldType: CustomFieldType, label: string, options: Prisma.JsonValue, value: string | number | boolean | string[] | null, ): void { // null ist immer erlaubt (Feld leeren) if (value === null) return; switch (fieldType) { case CustomFieldType.TEXT: case CustomFieldType.TEXTAREA: case CustomFieldType.URL: if (typeof value !== 'string') { throw new BadRequestException( `Feld "${label}" erwartet einen String-Wert`, ); } break; case CustomFieldType.NUMBER: if (typeof value !== 'number' && typeof value !== 'string') { throw new BadRequestException( `Feld "${label}" erwartet einen numerischen Wert`, ); } if (isNaN(Number(value))) { throw new BadRequestException( `Feld "${label}": "${String(value)}" ist kein gueltiger numerischer Wert`, ); } break; case CustomFieldType.DATE: if (typeof value !== 'string' || isNaN(Date.parse(value))) { throw new BadRequestException( `Feld "${label}" erwartet ein gueltiges Datum (ISO 8601)`, ); } break; case CustomFieldType.CHECKBOX: if (typeof value !== 'boolean') { throw new BadRequestException( `Feld "${label}" erwartet einen Boolean-Wert`, ); } break; case CustomFieldType.DROPDOWN: { if (typeof value !== 'string') { throw new BadRequestException( `Feld "${label}" erwartet einen String-Wert (Auswahl)`, ); } const opts = options as Array<{ value: string; label: string }> | null; if (opts && !opts.some((o) => o.value === value)) { throw new BadRequestException( `Feld "${label}": "${value}" ist keine gueltige Option`, ); } break; } case CustomFieldType.MULTI_SELECT: { if (!Array.isArray(value)) { throw new BadRequestException( `Feld "${label}" erwartet ein Array von Werten`, ); } const msOpts = options as Array<{ value: string; label: string }> | null; if (msOpts) { const validValues = new Set(msOpts.map((o) => o.value)); for (const v of value) { if (!validValues.has(v)) { throw new BadRequestException( `Feld "${label}": "${v}" ist keine gueltige Option`, ); } } } break; } } } }