INSIGHT-MVP/packages/crm-service/src/custom-fields/custom-fields.service.ts
Thomas Reitz aaedf68085 feat(crm): Phase 2.1 Custom Fields — backend + frontend integration
Backend (CRM expert): Custom field definitions CRUD, bulk value upsert,
7 endpoints, Prisma schema with CustomFieldDef + CustomFieldValue tables.

Frontend: Types, API, hooks, admin settings page with field management,
CustomFieldsDisplay for detail pages, CustomFieldsForm for edit modals.
Also fix Vite allowedHosts for insight.xinion.lan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:22:57 +01:00

577 lines
17 KiB
TypeScript

// ============================================================
// 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<CustomFieldEntry[]> {
// 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;
}
}
}
}