Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/crm/customFieldService.server.ts
T

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");
}