256 lines
11 KiB
TypeScript
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 } });
|
|
});
|
|
}
|