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

209 lines
8.6 KiB
TypeScript

// WF-P8 board admin write path. Shared by the S07 board table
// (admin.pipelines._index) and the S04 board settings
// (admin.pipelines.$pipelineId._index): both screens mutate the same boards,
// columns, access rules and field configs, so they must not grow two copies of
// the intent handling that can drift apart.
//
// Every handler is keyed by the ids in the form data and re-checks
// `projects.admin` through the caller's context, so a route cannot widen the
// permission by forwarding here.
import { upsertAuftragPipelineStageConfig } from "~/utils/integrations/projects/auftragService.server";
import { resolveOpenAccessRequests } from "~/utils/integrations/projects/pipelineAccessRequest.server";
import { projectActionErrorResponse } from "~/utils/integrations/projects/projectAccess.server";
import {
archivePipeline,
archivePipelineStage,
createPipeline,
duplicatePipeline,
reorderPipelineStages,
updatePipeline,
upsertPipelineAccess,
upsertPipelineFieldConfig,
upsertPipelineFieldVisibility,
upsertPipelineStage,
type CreatePipelineStageInput,
type PipelinePrincipalType,
type PipelineStageKind,
type PipelineTypHint,
} from "~/utils/integrations/projects/projectService.server";
export function formString(formData: FormData, name: string): string | null {
const value = formData.get(name);
return typeof value === "string" ? value : null;
}
export function formBoolean(formData: FormData, name: string): boolean | undefined {
const value = formString(formData, name);
if (value === null) return undefined;
return value === "true" || value === "1" || value === "on";
}
export function formNumber(formData: FormData, name: string): number | undefined {
const value = formString(formData, name);
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function formStages(formData: FormData): CreatePipelineStageInput[] {
const raw = formString(formData, "stages");
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((stage): stage is CreatePipelineStageInput => typeof stage === "object" && stage !== null && "name" in stage && "stageKind" in stage);
}
function formStageIds(formData: FormData): string[] {
const raw = formString(formData, "stageIds");
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === "string");
}
/**
* Runs one board-admin intent. Returns the Response the route hands back, so an
* unknown intent stays a 400 here instead of silently succeeding.
*/
export async function handlePipelineAdminAction(input: {
tenantId: string;
userId: string;
formData: FormData;
}): Promise<Response> {
const { tenantId, userId, formData } = input;
const intent = formString(formData, "_action");
const success = (messageKey: string, payload: Record<string, unknown> = {}) => Response.json({ ok: true, intent, messageKey, ...payload });
try {
if (intent === "createPipeline") {
const pipeline = await createPipeline({
tenantId,
userId,
name: formString(formData, "name") ?? "",
typHint: (formString(formData, "typHint") as PipelineTypHint | null) ?? "custom",
position: formNumber(formData, "position"),
stages: formStages(formData),
});
return success("projects.toast.pipelineCreated", { pipeline });
}
if (intent === "updatePipeline") {
const pipeline = await updatePipeline({
tenantId,
pipelineId: formString(formData, "pipelineId") ?? "",
name: formString(formData, "name"),
position: formNumber(formData, "position"),
typHint: formString(formData, "typHint") as PipelineTypHint | null,
// Only forward the successor when the form actually carried the field,
// so the rename form does not silently clear it.
...(formData.has("nachfolgePipelineId") ? { nachfolgePipelineId: formString(formData, "nachfolgePipelineId") } : {}),
});
return success("projects.toast.pipelineSaved", { pipeline });
}
if (intent === "archivePipeline") {
const pipeline = await archivePipeline({ tenantId, pipelineId: formString(formData, "pipelineId") ?? "" });
return success("projects.toast.pipelineArchived", { pipeline });
}
if (intent === "duplicatePipeline") {
const pipeline = await duplicatePipeline({
tenantId,
userId,
pipelineId: formString(formData, "pipelineId") ?? "",
name: formString(formData, "name") ?? "",
});
return success("projects.toast.pipelineDuplicated", { pipeline });
}
if (intent === "upsertStage") {
await upsertPipelineStage({
tenantId,
pipelineId: formString(formData, "pipelineId") ?? "",
stageId: formString(formData, "stageId"),
name: formString(formData, "name") ?? "",
stageKind: (formString(formData, "stageKind") as PipelineStageKind | null) ?? "offen",
winProbability: formNumber(formData, "winProbability"),
color: formString(formData, "color") ?? undefined,
position: formNumber(formData, "position"),
});
return success("projects.toast.pipelineStageSaved");
}
if (intent === "archiveStage") {
await archivePipelineStage({ tenantId, stageId: formString(formData, "stageId") ?? "" });
return success("projects.toast.pipelineStageArchived");
}
if (intent === "reorderStages") {
await reorderPipelineStages({
tenantId,
pipelineId: formString(formData, "pipelineId") ?? "",
stageIds: formStageIds(formData),
});
return success("projects.toast.pipelineStagesReordered");
}
if (intent === "upsertAccess") {
const pipelineId = formString(formData, "pipelineId") ?? "";
const principalType = formString(formData, "principalType") as PipelinePrincipalType;
const principalId = formString(formData, "principalId") ?? "";
const kannSehen = formBoolean(formData, "kannSehen");
await upsertPipelineAccess({
tenantId,
pipelineId,
principalType,
principalId,
kannSehen,
kannBearbeiten: formBoolean(formData, "kannBearbeiten"),
});
if (principalType === "user" && kannSehen === true && principalId) {
// Granting a user visibility approves that user's open access
// requests for this pipeline (best-effort: the grant already stands).
try {
await resolveOpenAccessRequests({
tenantId,
pipelineId,
userId: principalId,
resolvedByUserId: userId,
status: "approved",
});
} catch {
// Best-effort: an unresolved request marker never blocks the grant.
}
}
return success("projects.toast.pipelineAccessSaved");
}
if (intent === "upsertFieldConfig") {
await upsertPipelineFieldConfig({
tenantId,
pipelineId: formString(formData, "pipelineId") ?? "",
stageId: formString(formData, "stageId"),
fieldKey: formString(formData, "fieldKey") ?? "",
sichtbar: formBoolean(formData, "sichtbar"),
pflicht: formBoolean(formData, "pflicht"),
reihenfolge: formNumber(formData, "reihenfolge"),
});
return success("projects.toast.pipelineFieldConfigSaved");
}
if (intent === "upsertFieldVisibility") {
await upsertPipelineFieldVisibility({
tenantId,
fieldConfigId: formString(formData, "fieldConfigId") ?? "",
principalType: formString(formData, "principalType") as PipelinePrincipalType,
principalId: formString(formData, "principalId") ?? "",
kannSehen: formBoolean(formData, "kannSehen"),
kannBearbeiten: formBoolean(formData, "kannBearbeiten"),
});
return success("projects.toast.pipelineFieldVisibilitySaved");
}
if (intent === "upsertAuftragStageConfig") {
await upsertAuftragPipelineStageConfig({
tenantId,
pipelineId: formString(formData, "pipelineId") ?? "",
stageId: formString(formData, "stageId") ?? "",
requiresAcceptanceProtocol: formBoolean(formData, "requiresAcceptanceProtocol"),
acceptanceFormTemplateId: formString(formData, "acceptanceFormTemplateId"),
autoArchiveAfterDays: formNumber(formData, "autoArchiveAfterDays") ?? null,
});
return success("projects.toast.pipelineAcceptanceRuleSaved");
}
return Response.json({ error: "projects.error.unsupportedAction" }, { status: 400 });
} catch (error) {
return projectActionErrorResponse(error);
}
}