Files
craftvia/src/server/actions/reports/signature.ts
T
msolarczekandClaude Opus 5 3883296300 Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht
- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2
- Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes,
  readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage
- Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als
  imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts
- Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf
- L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay
- PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF
- Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned
- Dateilinks in Auftragsdetail auf /files/[documentId]
- proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect
- ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams)

Gate: tsc, lint, build, 42/42 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:49:19 +02:00

69 lines
3.0 KiB
TypeScript

"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/documents/store";
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);
}
}