209 lines
8.6 KiB
TypeScript
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);
|
|
}
|
|
}
|