L2 Aufträge & Backoffice: Server Actions und /api/v1/work-orders

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:42 +02:00
co-authored by Claude Opus 5
parent 2311b35d8c
commit a962fa8be9
10 changed files with 755 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import { unstable_rethrow } from "next/navigation";
import type { ActionState } from "@/lib/work-orders/action-state";
import type { CompletionBlocker } from "@/lib/work-orders/status";
import { wallTimeToUtc } from "@/lib/work-orders/time";
import { writeAuditLog } from "@/server/audit";
import { ModuleDisabledError } from "@/server/modules";
import { ForbiddenError } from "@/server/rbac";
import { ServiceError } from "@/server/services/context";
/**
* Internal helpers of the work_orders actions (file starts with "_": not an action module,
* skipped by scripts/check-module-guards.ts). Maps thrown errors to a displayable ActionState
* without leaking internals (F-16) and audits RBAC denials.
*/
export async function toErrorState(
err: unknown,
audit: { tenantId?: string; actorId?: string; entity: string; entityId?: string },
): Promise<ActionState> {
unstable_rethrow(err);
const at = Date.now();
if (err instanceof ServiceError) {
return {
status: "error",
code: err.code,
message: err.message,
blockers: err.code === "blocked" ? (err.details as CompletionBlocker[]) : undefined,
at,
};
}
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) {
if (err instanceof ForbiddenError && audit.tenantId) {
try {
await writeAuditLog({ tenantId: audit.tenantId, actorId: audit.actorId, action: "denied", entity: audit.entity, entityId: audit.entityId, after: { reason: err.message } });
} catch (auditErr) {
console.error("[work_orders] audit-write-failed", auditErr);
}
}
return { status: "error", code: "forbidden", message: "forbidden", at };
}
console.error(`[work_orders] ${audit.entity} action failed`, err);
return { status: "error", code: "internal", message: "internal", at };
}
export const ok = (): ActionState => ({ status: "ok", at: Date.now() });
export function str(fd: FormData, key: string): string | undefined {
const v = fd.get(key);
if (typeof v !== "string") return undefined;
const t = v.trim();
return t ? t : undefined;
}
/** Present field → string or null (empty); absent field → undefined. */
export function nullable(fd: FormData, key: string): string | null | undefined {
if (!fd.has(key)) return undefined;
return str(fd, key) ?? null;
}
export function bool(fd: FormData, key: string): boolean {
const v = fd.get(key);
return v === "on" || v === "true" || v === "1";
}
/** "yes" | "no" | "" select → boolean | undefined */
export function triState(fd: FormData, key: string): boolean | undefined {
const v = str(fd, key);
return v === "yes" ? true : v === "no" ? false : undefined;
}
export function int(fd: FormData, key: string): number | undefined {
const v = str(fd, key);
if (v === undefined) return undefined;
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
/** datetime-local in tenant timezone → Date; empty → null; absent → undefined. */
export function wallTime(fd: FormData, key: string, tz: string): Date | null | undefined {
if (!fd.has(key)) return undefined;
const v = str(fd, key);
if (!v) return null;
return wallTimeToUtc(v, tz) ?? null;
}
/** Only internal detail links are allowed as return targets. */
export function detailPath(workOrderId: string, tab?: string): string {
const safeTab = tab && /^[a-z]{3,12}$/.test(tab) ? `?tab=${tab}` : "";
return `/work-orders/${encodeURIComponent(workOrderId)}${safeTab}`;
}
+135
View File
@@ -0,0 +1,135 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ActionState } from "@/lib/work-orders/action-state";
import { slugKey } from "@/lib/work-orders/schemas";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { uniqueKey } from "@/server/services/work-orders/_shared";
import {
createChecklistTemplate,
createOrderType,
updateChecklistTemplate,
updateNumberSequence,
updateOrderType,
} from "@/server/services/work-orders/settings";
import { bool, int, ok, str, toErrorState } from "./_form";
/** Settings of the work order module (order types, checklist templates, numbering) — `settings:templates`. */
const guard = moduleGuard("work_orders");
export async function createOrderTypeAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("settings:templates");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
const name = str(fd, "name") ?? "";
await createOrderType(ctxFromGuard(g), {
key: str(fd, "key") ?? slugKey(name),
name,
signatureRequired: bool(fd, "signatureRequired"),
active: true,
sortOrder: int(fd, "sortOrder") ?? 100,
});
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "order_type" });
}
revalidatePath("/settings/order-types");
return ok();
}
export async function updateOrderTypeAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const id = str(fd, "id") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("settings:templates");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await updateOrderType(ctxFromGuard(g), id, {
name: str(fd, "name") ?? "",
signatureRequired: bool(fd, "signatureRequired"),
active: bool(fd, "active"),
sortOrder: int(fd, "sortOrder") ?? 100,
});
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "order_type", entityId: id });
}
revalidatePath("/settings/order-types");
return ok();
}
/** Textarea lines → template items: "*" prefix = required, "[Foto]" suffix = requires photo. */
function parseItems(text: string) {
const keys = new Set<string>();
return text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
.slice(0, 100)
.map((line) => {
let label = line;
const required = label.startsWith("*");
if (required) label = label.slice(1).trim();
const requiresPhoto = /\[foto\]$/i.test(label);
if (requiresPhoto) label = label.replace(/\[foto\]$/i, "").trim();
return { key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200), required, requiresPhoto };
})
.filter((i) => i.label.length > 0);
}
function parsePhotos(text: string) {
const keys = new Set<string>();
return text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
.slice(0, 30)
.map((label) => ({ key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200) }));
}
export async function saveChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const id = str(fd, "id");
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("settings:templates");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
const input = {
name: str(fd, "name") ?? "",
orderTypeId: str(fd, "orderTypeId") ?? null,
active: bool(fd, "active"),
items: parseItems(String(fd.get("items") ?? "")),
requiredPhotos: parsePhotos(String(fd.get("requiredPhotos") ?? "")),
};
const ctx = ctxFromGuard(g);
if (id) await updateChecklistTemplate(ctx, id, input);
else await createChecklistTemplate(ctx, input);
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "checklist_template", entityId: id });
}
revalidatePath("/settings/checklists");
return ok();
}
export async function updateNumberingAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("settings:templates");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await updateNumberSequence(ctxFromGuard(g), {
key: str(fd, "key") ?? "",
prefix: String(fd.get("prefix") ?? "").trim(),
padding: str(fd, "padding") ?? "",
});
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "number_sequence" });
}
revalidatePath("/settings/numbering");
return ok();
}
@@ -0,0 +1,304 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import type { ActionState } from "@/lib/work-orders/action-state";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
import { addChecklistItem, addPhotoRequirement, applyChecklistTemplate, removeChecklistItem, removePhotoRequirement } from "@/server/services/work-orders/checklist";
import { applySyncConflict, discardSyncConflict } from "@/server/services/work-orders/conflicts";
import { createWorkOrder } from "@/server/services/work-orders/create";
import { archiveWorkOrderDocument } from "@/server/services/work-orders/documents";
import { addMaterialPlan, removeMaterialPlan } from "@/server/services/work-orders/materials";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { updateWorkOrder } from "@/server/services/work-orders/update";
import { bool, detailPath, int, nullable, ok, str, toErrorState, triState, wallTime } from "./_form";
/**
* Backoffice server actions of module "work_orders" — thin adapters:
* moduleGuard → FormData → service (permission, scope, Zod, audit, event) → revalidate/redirect.
*/
const guard = moduleGuard("work_orders");
export async function createWorkOrderAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
let id: string;
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
const ctx = ctxFromGuard(g);
const tz = await tenantTimezone(ctx);
const created = await createWorkOrder(ctx, {
title: str(fd, "title") ?? "",
customerId: str(fd, "customerId") ?? "",
siteId: str(fd, "siteId") ?? null,
contactId: str(fd, "contactId") ?? null,
orderTypeId: str(fd, "orderTypeId") ?? null,
priority: (str(fd, "priority") as never) ?? "normal",
status: str(fd, "status") === "planned" ? "planned" : "draft",
description: str(fd, "description"),
scope: str(fd, "scope"),
plannedStart: wallTime(fd, "plannedStart", tz) ?? null,
plannedEnd: wallTime(fd, "plannedEnd", tz) ?? null,
signatureRequired: triState(fd, "signatureRequired"),
billingType: (str(fd, "billingType") as never) ?? null,
internalNotes: str(fd, "internalNotes"),
technicianNotes: str(fd, "technicianNotes"),
externalOrderNumber: str(fd, "externalOrderNumber"),
offerNumber: str(fd, "offerNumber"),
});
id = created.id;
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order" });
}
revalidatePath("/work-orders");
redirect(detailPath(id));
}
export async function updateWorkOrderAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
const ctx = ctxFromGuard(g);
const tz = await tenantTimezone(ctx);
const patch: Record<string, unknown> = {};
for (const k of ["title", "priority", "billingType"]) if (fd.has(k)) patch[k] = str(fd, k) ?? null;
for (const k of ["siteId", "contactId", "orderTypeId", "description", "scope", "internalNotes", "technicianNotes", "externalOrderNumber", "offerNumber"]) {
const v = nullable(fd, k);
if (v !== undefined) patch[k] = v;
}
for (const k of ["plannedStart", "plannedEnd"]) {
const v = wallTime(fd, k, tz);
if (v !== undefined) patch[k] = v;
}
const signature = triState(fd, "signatureRequired");
if (signature !== undefined) patch.signatureRequired = signature;
if (patch.title === null) patch.title = "";
await updateWorkOrder(ctx, workOrderId, patch, int(fd, "baseVersion"));
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
redirect(detailPath(workOrderId));
}
export async function transitionAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
// Permission depends on the concrete transition → decided by the service (mayTransition).
const g = await guard();
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await transitionWorkOrder(ctxFromGuard(g), {
workOrderId,
to: (str(fd, "to") ?? "") as never,
reason: str(fd, "reason") ?? null,
baseVersion: int(fd, "baseVersion"),
});
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath("/work-orders");
redirect(detailPath(workOrderId, str(fd, "returnTab")));
}
export async function assignAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:assign");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await assignWorkOrder(ctxFromGuard(g), {
workOrderId,
teamId: str(fd, "teamId") ?? "",
userIds: fd.getAll("userIds").filter((v): v is string => typeof v === "string" && v.length > 0),
teamLeadUserId: str(fd, "teamLeadUserId") ?? null,
baseVersion: int(fd, "baseVersion"),
});
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath("/work-orders");
redirect(detailPath(workOrderId));
}
export async function addMaterialPlanAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await addMaterialPlan(ctxFromGuard(g), workOrderId, {
name: str(fd, "name") ?? "",
articleNumber: str(fd, "articleNumber"),
plannedQuantity: (str(fd, "plannedQuantity") ?? "").replace(",", ".") as never,
unit: str(fd, "unit") ?? "",
notes: str(fd, "notes"),
});
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function removeMaterialPlanAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await removeMaterialPlan(ctxFromGuard(g), str(fd, "planId") ?? "");
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function addChecklistItemAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await addChecklistItem(ctxFromGuard(g), workOrderId, {
label: str(fd, "label") ?? "",
required: bool(fd, "required"),
requiresPhoto: bool(fd, "requiresPhoto"),
});
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function removeChecklistItemAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await removeChecklistItem(ctxFromGuard(g), str(fd, "itemId") ?? "");
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function addPhotoRequirementAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await addPhotoRequirement(ctxFromGuard(g), workOrderId, { label: str(fd, "label") ?? "" });
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function removePhotoRequirementAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await removePhotoRequirement(ctxFromGuard(g), str(fd, "requirementId") ?? "");
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function applyChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await applyChecklistTemplate(ctxFromGuard(g), workOrderId, str(fd, "templateId") ?? "");
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function archiveDocumentAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const workOrderId = str(fd, "workOrderId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("document:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await archiveWorkOrderDocument(ctxFromGuard(g), str(fd, "documentId") ?? "");
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "document", entityId: str(fd, "documentId") });
}
revalidatePath(`/work-orders/${workOrderId}`);
return ok();
}
export async function applySyncConflictAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const opId = str(fd, "opId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await applySyncConflict(ctxFromGuard(g), opId);
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId });
}
revalidatePath("/work-orders/conflicts");
return ok();
}
export async function discardSyncConflictAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const opId = str(fd, "opId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("work_order:write");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await discardSyncConflict(ctxFromGuard(g), opId);
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId });
}
revalidatePath("/work-orders/conflicts");
return ok();
}