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

256 lines
11 KiB
TypeScript

// WF-P8 S05 (Detail-Schablone Edit-Mode) + M05 (Modal Bestandsfeld) — the
// project detail template of a pipeline: which field sits in which section, in
// which order, and when it becomes mandatory.
//
// Two invariants from the screen, enforced here rather than in the UI:
// - system fields may be reordered, locked, or moved back into the pool, but
// never deleted (`istSystem`);
// - projektname, status and prioritaet are always visible and can neither be
// hidden nor unfiled (`ALWAYS_VISIBLE_FIELD_KEYS`).
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
export class PipelineFieldTemplateError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "PipelineFieldTemplateError";
this.messageKey = messageKey;
}
}
/** Always-visible fields per S05 ("Projektname, Status und Priorität bleiben immer sichtbar"). */
export const ALWAYS_VISIBLE_FIELD_KEYS: readonly string[] = ["projektname", "status", "prioritaet"];
/**
* Fixed system info rendered outside the configurable template ("Projekt-ID,
* Erstellt und Board-Spalte sind feste Systeminfos außerhalb dieser
* Konfiguration"). Listed so the UI can render them read-only and the service
* can reject attempts to file them into a group.
*/
export const FIXED_SYSTEM_FIELD_KEYS: readonly string[] = ["projektNummer", "createdAt", "boardColumn"];
export type FieldGroupRow = {
id: string;
name: string;
reihenfolge: number;
istSystem: boolean;
};
export type TemplateFieldRow = {
id: string;
fieldKey: string;
groupId: string | null;
sichtbar: boolean;
pflicht: boolean;
pflichtAbStageId: string | null;
hinweis: string | null;
istSystem: boolean;
gesperrt: boolean;
reihenfolge: number;
};
export type PipelineFieldTemplate = {
pipelineId: string;
pipelineName: string;
groups: FieldGroupRow[];
/** Fields filed into a group, ordered by group then position. */
fields: TemplateFieldRow[];
/** "Bestandsfelder außerhalb der Schablone" — configured but unfiled. */
poolFields: TemplateFieldRow[];
/** Stages offered by "Pflicht ab Board-Spalte". */
stages: { id: string; name: string; position: number }[];
};
function normalizeText(value?: string | null): string | null {
const normalized = value?.trim();
return normalized || null;
}
async function assertPipelineWithTx(tx: TenantRlsClient, tenantId: string, pipelineId: string) {
const pipeline = await tx.pipeline.findFirst({
where: { id: pipelineId, tenantId, archivedAt: null },
select: { id: true, name: true },
});
if (!pipeline) throw new CatalogError(foundationErrors.notFound, { pipelineId });
return pipeline;
}
export async function loadPipelineFieldTemplate(input: { tenantId: string; pipelineId: string }): Promise<PipelineFieldTemplate> {
return withTenantRLS(input.tenantId, async (tx) => {
const pipeline = await assertPipelineWithTx(tx, input.tenantId, input.pipelineId);
const [groups, configs, stages] = await Promise.all([
tx.pipelineFieldGroup.findMany({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId },
orderBy: { reihenfolge: "asc" },
}),
// The template is the pipeline-wide layer (`stageId: null`); per-stage
// overrides stay the concern of the pipeline admin screen.
tx.pipelineFieldConfig.findMany({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, stageId: null },
orderBy: { reihenfolge: "asc" },
}),
tx.pipelineStage.findMany({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId, archivedAt: null },
orderBy: { position: "asc" },
select: { id: true, name: true, position: true },
}),
]);
const toRow = (config: (typeof configs)[number]): TemplateFieldRow => ({
id: config.id,
fieldKey: config.fieldKey,
groupId: config.groupId,
sichtbar: config.sichtbar,
pflicht: config.pflicht,
pflichtAbStageId: config.pflichtAbStageId,
hinweis: config.hinweis,
istSystem: config.istSystem,
gesperrt: config.gesperrt,
reihenfolge: config.reihenfolge,
});
return {
pipelineId: pipeline.id,
pipelineName: pipeline.name,
groups: groups.map((group) => ({ id: group.id, name: group.name, reihenfolge: group.reihenfolge, istSystem: group.istSystem })),
fields: configs.filter((config) => config.groupId).map(toRow),
poolFields: configs.filter((config) => !config.groupId).map(toRow),
stages,
};
});
}
export async function createFieldGroup(input: { tenantId: string; pipelineId: string; name: string }): Promise<FieldGroupRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new PipelineFieldTemplateError("projects.error.fieldGroupNameRequired");
await assertPipelineWithTx(tx, input.tenantId, input.pipelineId);
const duplicate = await tx.pipelineFieldGroup.findFirst({
where: { pipelineId: input.pipelineId, name },
select: { id: true },
});
if (duplicate) throw new PipelineFieldTemplateError("projects.error.fieldGroupNameTaken");
const last = await tx.pipelineFieldGroup.findFirst({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId },
orderBy: { reihenfolge: "desc" },
select: { reihenfolge: true },
});
const group = await tx.pipelineFieldGroup.create({
data: {
tenantId: input.tenantId,
pipelineId: input.pipelineId,
name,
reihenfolge: (last?.reihenfolge ?? -1) + 1,
},
});
return { id: group.id, name: group.name, reihenfolge: group.reihenfolge, istSystem: group.istSystem };
});
}
export async function renameFieldGroup(input: { tenantId: string; pipelineId: string; groupId: string; name: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const name = normalizeText(input.name);
if (!name) throw new PipelineFieldTemplateError("projects.error.fieldGroupNameRequired");
const group = await tx.pipelineFieldGroup.findFirst({
where: { id: input.groupId, tenantId: input.tenantId, pipelineId: input.pipelineId },
select: { id: true },
});
if (!group) throw new CatalogError(foundationErrors.notFound, { groupId: input.groupId });
const duplicate = await tx.pipelineFieldGroup.findFirst({
where: { pipelineId: input.pipelineId, name, NOT: { id: group.id } },
select: { id: true },
});
if (duplicate) throw new PipelineFieldTemplateError("projects.error.fieldGroupNameTaken");
await tx.pipelineFieldGroup.update({ where: { id: group.id }, data: { name } });
});
}
/**
* Delete a group. System groups stay. Its fields are not deleted — they fall
* back into the "Bestandsfelder" pool (`groupId: null` via the FK's SET NULL).
*/
export async function deleteFieldGroup(input: { tenantId: string; pipelineId: string; groupId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const group = await tx.pipelineFieldGroup.findFirst({
where: { id: input.groupId, tenantId: input.tenantId, pipelineId: input.pipelineId },
select: { id: true, istSystem: true },
});
if (!group) throw new CatalogError(foundationErrors.notFound, { groupId: input.groupId });
if (group.istSystem) throw new PipelineFieldTemplateError("projects.error.fieldGroupSystemUndeletable");
await tx.pipelineFieldGroup.delete({ where: { id: group.id } });
});
}
export async function reorderFieldGroups(input: { tenantId: string; pipelineId: string; orderedGroupIds: readonly string[] }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const groups = await tx.pipelineFieldGroup.findMany({
where: { tenantId: input.tenantId, pipelineId: input.pipelineId },
select: { id: true },
});
const known = new Set(groups.map((group) => group.id));
// Reject a partial order outright: silently appending the missing groups
// would scramble a layout the user never touched.
if (input.orderedGroupIds.length !== known.size || input.orderedGroupIds.some((id) => !known.has(id))) {
throw new PipelineFieldTemplateError("projects.error.fieldGroupOrderIncomplete");
}
for (const [index, groupId] of input.orderedGroupIds.entries()) {
await tx.pipelineFieldGroup.update({ where: { id: groupId }, data: { reihenfolge: index } });
}
});
}
/**
* Move a field into a group (or back into the pool with `groupId: null`) and
* set its position within that group. This is the drag-and-drop write path of S05.
*/
export async function moveFieldToGroup(input: {
tenantId: string;
pipelineId: string;
fieldConfigId: string;
groupId: string | null;
reihenfolge: number;
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const config = await tx.pipelineFieldConfig.findFirst({
where: { id: input.fieldConfigId, tenantId: input.tenantId, pipelineId: input.pipelineId, stageId: null },
select: { id: true, fieldKey: true },
});
if (!config) throw new CatalogError(foundationErrors.notFound, { fieldConfigId: input.fieldConfigId });
if (FIXED_SYSTEM_FIELD_KEYS.includes(config.fieldKey)) {
throw new PipelineFieldTemplateError("projects.error.fieldFixedSystemInfo");
}
if (input.groupId === null && ALWAYS_VISIBLE_FIELD_KEYS.includes(config.fieldKey)) {
throw new PipelineFieldTemplateError("projects.error.fieldAlwaysVisible");
}
if (input.groupId) {
const group = await tx.pipelineFieldGroup.findFirst({
where: { id: input.groupId, tenantId: input.tenantId, pipelineId: input.pipelineId },
select: { id: true },
});
if (!group) throw new CatalogError(foundationErrors.notFound, { groupId: input.groupId });
}
await tx.pipelineFieldConfig.update({
where: { id: config.id },
data: { groupId: input.groupId, reihenfolge: input.reihenfolge },
});
});
}
/**
* Permanently remove a non-system custom field from the template ("Kopieren und
* dauerhaftes Löschen mit Datenverlust-Hinweis"). System fields are refused.
*/
export async function deleteTemplateField(input: { tenantId: string; pipelineId: string; fieldConfigId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const config = await tx.pipelineFieldConfig.findFirst({
where: { id: input.fieldConfigId, tenantId: input.tenantId, pipelineId: input.pipelineId, stageId: null },
select: { id: true, istSystem: true, fieldKey: true },
});
if (!config) throw new CatalogError(foundationErrors.notFound, { fieldConfigId: input.fieldConfigId });
if (config.istSystem || ALWAYS_VISIBLE_FIELD_KEYS.includes(config.fieldKey)) {
throw new PipelineFieldTemplateError("projects.error.fieldSystemUndeletable");
}
await tx.pipelineFieldConfig.delete({ where: { id: config.id } });
});
}