274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
// R-P6 (P6 CRM core, Variante A) — tenant custom-field service.
|
|
//
|
|
// Definitions (CustomFieldDefinition) describe a polymorphic field per entity
|
|
// type; values (CustomFieldValue) hold the concrete data for a single entity.
|
|
// `entityId` carries NO DB foreign key — this service validates the entityType
|
|
// match. Always tenant-scoped via withTenantRLS. NO React/UI imports. Audit +
|
|
// Activity are wired by the routes (crmAudit), not here.
|
|
|
|
import { Prisma } from "@hivegrid/db/prisma-browser";
|
|
import { CatalogError, foundationErrors, type JsonValue } from "@hivegrid/foundation";
|
|
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
|
|
import { CrmServiceError } from "./crmService.server";
|
|
|
|
export type CustomFieldEntityType = "projekt" | "kontakt" | "lead" | "rechnung";
|
|
export type CustomFieldDatentyp =
|
|
| "text"
|
|
| "textarea"
|
|
| "zahl"
|
|
| "waehrung"
|
|
| "datum"
|
|
| "datum_range"
|
|
| "select"
|
|
| "multiselect"
|
|
| "boolean"
|
|
| "email"
|
|
| "telefon"
|
|
| "datei"
|
|
// WF-P8 S05 additions; see the enum comment in crm.prisma.
|
|
| "prozent"
|
|
| "datum_zeit"
|
|
| "person_team"
|
|
| "adresse"
|
|
| "link";
|
|
|
|
export type CustomFieldDefinitionRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
entityType: CustomFieldEntityType;
|
|
key: string;
|
|
label: string;
|
|
datentyp: CustomFieldDatentyp;
|
|
optionen: JsonValue | null;
|
|
archiviert: boolean;
|
|
isDemoData: boolean;
|
|
createdAt: Date;
|
|
};
|
|
|
|
export type CustomFieldValueRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
definitionId: string;
|
|
entityType: CustomFieldEntityType;
|
|
entityId: string;
|
|
wert: JsonValue | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
function toJsonInput(value: JsonValue | null | undefined): Prisma.InputJsonValue | typeof Prisma.JsonNull {
|
|
return value === null || value === undefined ? Prisma.JsonNull : (value as Prisma.InputJsonValue);
|
|
}
|
|
|
|
// --- Definitions -----------------------------------------------------------
|
|
|
|
export async function createCustomFieldDefinition(input: {
|
|
tenantId: string;
|
|
entityType: CustomFieldEntityType;
|
|
key: string;
|
|
label: string;
|
|
datentyp: CustomFieldDatentyp;
|
|
optionen?: JsonValue | null;
|
|
isDemoData?: boolean;
|
|
}): Promise<CustomFieldDefinitionRow> {
|
|
const key = input.key.trim();
|
|
const label = input.label.trim();
|
|
if (!key) {
|
|
throw new CrmServiceError("crm.error.fieldKeyRequired");
|
|
}
|
|
if (!label) {
|
|
throw new CrmServiceError("crm.error.fieldLabelRequired");
|
|
}
|
|
try {
|
|
const created = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.customFieldDefinition.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
entityType: input.entityType,
|
|
key,
|
|
label,
|
|
datentyp: input.datentyp,
|
|
optionen: toJsonInput(input.optionen),
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
}),
|
|
);
|
|
return created as CustomFieldDefinitionRow;
|
|
} catch (error) {
|
|
if (isUniqueConstraintError(error)) {
|
|
throw new CrmServiceError("crm.error.fieldKeyExists");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function updateCustomFieldDefinition(input: {
|
|
tenantId: string;
|
|
definitionId: string;
|
|
label?: string;
|
|
datentyp?: CustomFieldDatentyp;
|
|
optionen?: JsonValue | null;
|
|
archiviert?: boolean;
|
|
}): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
const existing = await tx.customFieldDefinition.findFirst({
|
|
where: { id: input.definitionId, tenantId: input.tenantId },
|
|
select: { id: true, datentyp: true },
|
|
});
|
|
if (!existing) {
|
|
throw new CatalogError(foundationErrors.notFound, { definitionId: input.definitionId });
|
|
}
|
|
// Retyping a field reinterprets every value already stored under it: a date
|
|
// read as a number, a select value with no matching option. select <-> multiselect
|
|
// is the exception, both hold option values, so the data keeps its meaning.
|
|
if (input.datentyp !== undefined && input.datentyp !== existing.datentyp) {
|
|
const optionTypes = new Set(["select", "multiselect"]);
|
|
const staysCompatible = optionTypes.has(input.datentyp) && optionTypes.has(existing.datentyp);
|
|
if (!staysCompatible) {
|
|
const valueCount = await tx.customFieldValue.count({ where: { tenantId: input.tenantId, definitionId: input.definitionId } });
|
|
if (valueCount > 0) throw new CrmServiceError("crm.error.customFieldTypeLocked");
|
|
}
|
|
}
|
|
await tx.customFieldDefinition.updateMany({
|
|
where: { id: input.definitionId, tenantId: input.tenantId },
|
|
data: {
|
|
...(input.label !== undefined ? { label: input.label.trim() } : {}),
|
|
...(input.datentyp !== undefined ? { datentyp: input.datentyp } : {}),
|
|
...(input.optionen !== undefined ? { optionen: toJsonInput(input.optionen) } : {}),
|
|
...(input.archiviert !== undefined ? { archiviert: input.archiviert } : {}),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function archiveCustomFieldDefinition(input: { tenantId: string; definitionId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
const existing = await tx.customFieldDefinition.findFirst({ where: { id: input.definitionId, tenantId: input.tenantId }, select: { id: true } });
|
|
if (!existing) {
|
|
throw new CatalogError(foundationErrors.notFound, { definitionId: input.definitionId });
|
|
}
|
|
await tx.customFieldDefinition.updateMany({ where: { id: input.definitionId, tenantId: input.tenantId }, data: { archiviert: true } });
|
|
});
|
|
}
|
|
|
|
export async function listCustomFieldDefinitions(input: { tenantId: string; entityType: CustomFieldEntityType; includeArchived?: boolean }): Promise<CustomFieldDefinitionRow[]> {
|
|
const rows = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.customFieldDefinition.findMany({
|
|
where: { tenantId: input.tenantId, entityType: input.entityType, ...(input.includeArchived ? {} : { archiviert: false }) },
|
|
orderBy: { createdAt: "asc" },
|
|
}),
|
|
);
|
|
return rows as CustomFieldDefinitionRow[];
|
|
}
|
|
|
|
// --- Values ----------------------------------------------------------------
|
|
|
|
export async function setCustomFieldValue(input: {
|
|
tenantId: string;
|
|
definitionId: string;
|
|
entityType: CustomFieldEntityType;
|
|
entityId: string;
|
|
wert: JsonValue | null;
|
|
}): Promise<CustomFieldValueRow> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
const definition = await tx.customFieldDefinition.findFirst({ where: { id: input.definitionId, tenantId: input.tenantId }, select: { entityType: true } });
|
|
if (!definition) {
|
|
throw new CatalogError(foundationErrors.notFound, { definitionId: input.definitionId });
|
|
}
|
|
if (definition.entityType !== input.entityType) {
|
|
throw new CrmServiceError("crm.error.entityTypeMismatch");
|
|
}
|
|
const value = await tx.customFieldValue.upsert({
|
|
where: { definitionId_entityId: { definitionId: input.definitionId, entityId: input.entityId } },
|
|
update: { wert: toJsonInput(input.wert) },
|
|
create: { tenantId: input.tenantId, definitionId: input.definitionId, entityType: input.entityType, entityId: input.entityId, wert: toJsonInput(input.wert) },
|
|
});
|
|
return value as CustomFieldValueRow;
|
|
});
|
|
}
|
|
|
|
export type CustomFieldValueWithDefinition = { definition: CustomFieldDefinitionRow; wert: JsonValue | null };
|
|
|
|
export async function getCustomFieldValues(input: { tenantId: string; entityType: CustomFieldEntityType; entityId: string }): Promise<CustomFieldValueWithDefinition[]> {
|
|
const rows = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.customFieldValue.findMany({ where: { tenantId: input.tenantId, entityType: input.entityType, entityId: input.entityId }, include: { definition: true }, orderBy: { createdAt: "asc" } }),
|
|
);
|
|
return rows.map((row) => ({ definition: row.definition as CustomFieldDefinitionRow, wert: (row.wert as JsonValue | null) ?? null }));
|
|
}
|
|
|
|
// --- SHK demo-seed mechanism (DoD 6) ---------------------------------------
|
|
|
|
export type ShkCustomFieldTemplateEntry = {
|
|
entityType: CustomFieldEntityType;
|
|
key: string;
|
|
label: string;
|
|
datentyp: CustomFieldDatentyp;
|
|
optionen?: JsonValue;
|
|
};
|
|
|
|
/**
|
|
* SHK (Sanitär-Heizung-Klima) custom-field template. Filled with Geerkens later
|
|
* (E3). The seeding mechanism below is complete; the template is intentionally
|
|
* empty so no SHK fields are invented before the domain workshop.
|
|
*/
|
|
export const SHK_CUSTOM_FIELD_TEMPLATE: ReadonlyArray<ShkCustomFieldTemplateEntry> = [];
|
|
|
|
/**
|
|
* Idempotently create the SHK template fields for a tenant. Tenant-created
|
|
* defaults are normal rows; only an explicit demo seed may opt into demo rows.
|
|
* Existing fields with the same (tenant, entityType, key) are skipped. With
|
|
* the empty template this is a no-op that returns 0.
|
|
*/
|
|
export type SeedShkCustomFieldTemplateInput = { tenantId: string; isDemoData?: boolean };
|
|
|
|
export async function seedShkCustomFieldTemplateWithTx(
|
|
tx: TenantRlsClient,
|
|
input: SeedShkCustomFieldTemplateInput,
|
|
): Promise<{ created: number }> {
|
|
let created = 0;
|
|
const isDemoData = input.isDemoData ?? false;
|
|
for (const entry of SHK_CUSTOM_FIELD_TEMPLATE) {
|
|
const exists = await tx.customFieldDefinition.findFirst({
|
|
where: { tenantId: input.tenantId, entityType: entry.entityType, key: entry.key },
|
|
select: { id: true },
|
|
});
|
|
if (exists) {
|
|
continue;
|
|
}
|
|
await tx.customFieldDefinition.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
entityType: entry.entityType,
|
|
key: entry.key,
|
|
label: entry.label,
|
|
datentyp: entry.datentyp,
|
|
optionen: toJsonInput(entry.optionen ?? null),
|
|
isDemoData,
|
|
},
|
|
});
|
|
created += 1;
|
|
}
|
|
return { created };
|
|
}
|
|
|
|
export async function seedShkCustomFieldTemplate(input: SeedShkCustomFieldTemplateInput): Promise<{ created: number }> {
|
|
// Avoid opening an RLS transaction while the versioned template is empty.
|
|
// Once entries exist, the wrapper still binds the tenant before any query.
|
|
if (SHK_CUSTOM_FIELD_TEMPLATE.length === 0) {
|
|
return { created: 0 };
|
|
}
|
|
return withTenantRLS(input.tenantId, (tx) => seedShkCustomFieldTemplateWithTx(tx, input));
|
|
}
|
|
|
|
/** Bulk-remove demo custom-field definitions on the production switch-over. */
|
|
export async function clearDemoCustomFields(input: { tenantId: string }): Promise<{ deleted: number }> {
|
|
const result = await withTenantRLS(input.tenantId, (tx) => tx.customFieldDefinition.deleteMany({ where: { tenantId: input.tenantId, isDemoData: true } }));
|
|
return { deleted: result.count };
|
|
}
|
|
|
|
// --- internal --------------------------------------------------------------
|
|
|
|
function isUniqueConstraintError(error: unknown): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "P2002");
|
|
}
|