mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +02:00
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>
577 lines
17 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|
|
}
|