L5 Berichte & Unterschrift: Server Actions und API v1

Actions mit moduleGuard("reports"); POST daily-report/completion-report, POST approve,
GET pdf sowie Dateiauslieferung für im Bericht referenzierte Dokumente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:40 +02:00
co-authored by Claude Opus 5
parent 8a6fdd8f7a
commit 8f53df6208
8 changed files with 279 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ReportActionState } from "@/lib/reports/action-state";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { requireVisibleReport } from "@/server/services/reports/common";
import { captureSignature } from "@/server/services/reports/signature";
// TODO(merge L4): signature PNG could go through POST /api/v1/uploads once available
import { storeFile } from "@/server/services/reports/_stubs/documents";
import { errorState, okState, str } from "./_state";
const guard = moduleGuard("reports");
const MAX_PNG_BYTES = 2 * 1024 * 1024;
/**
* Capture signature or documented exception. Form fields: reportId, outcome, signerName, signerRole, reason,
* confirmationText, signaturePng (data:image/png;base64,… from the signature pad; only for outcome=signed).
*/
export async function captureSignatureAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
try {
const ctx = ctxFromGuard(await guard("report:write"));
const reportId = str(fd, "reportId") ?? "";
const outcome = str(fd, "outcome") ?? "";
const report = await requireVisibleReport(ctx, reportId);
let imageDocumentId: string | null = null;
const png = str(fd, "signaturePng") ?? "";
if (outcome === "signed") {
const m = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(png);
if (!m) throw new ServiceError("invalid", "signature image required", { field: "image" });
const bytes = Buffer.from(m[1], "base64");
if (bytes.byteLength > MAX_PNG_BYTES) throw new ServiceError("invalid", "signature image too large", { field: "image" });
if (!str(fd, "signerName")?.trim()) throw new ServiceError("invalid", "signer name required", { field: "signerName" });
const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { id: true, customerId: true, siteId: true } });
const doc = await storeFile(ctx, {
bytes,
fileName: `unterschrift-${report.id}.png`,
declaredMime: "image/png",
category: "signature",
visibility: "customer_report",
links: { customerId: wo.customerId, siteId: wo.siteId, workOrderId: wo.id },
});
imageDocumentId = doc.id;
}
try {
await captureSignature(ctx, {
reportId,
outcome: outcome as Parameters<typeof captureSignature>[1]["outcome"],
signerName: str(fd, "signerName"),
signerRole: str(fd, "signerRole"),
reason: str(fd, "reason"),
confirmationText: str(fd, "confirmationText") ?? "",
imageDocumentId,
});
} catch (err) {
if (imageDocumentId) await ctx.db.document.update({ where: { id: imageDocumentId }, data: { deletedAt: new Date() } });
throw err;
}
revalidatePath(`/m/orders/${report.workOrderId}/sign`);
revalidatePath(`/m/orders/${report.workOrderId}/report`);
revalidatePath(`/reports/${report.id}`);
return okState(report.id);
} catch (err) {
return errorState(err);
}
}