import type { Signature } from "@prisma/client"; import { z } from "zod"; import { SIGNATURE_OUTCOMES, SIGNATURE_REASON_REQUIRED, type SignatureBlock } from "@/lib/reports/content"; import { can, assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; // TODO(merge L2): import from "@/server/services/work-orders/transition" import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { auditReport, contentOf, requireVisibleReport } from "./common"; const optText = (max: number) => z .string() .trim() .max(max) .nullish() .transform((v) => (v ? v : null)); export const captureSignatureSchema = z.object({ reportId: z.string().min(1).max(64), outcome: z.enum(SIGNATURE_OUTCOMES), signerName: optText(200), signerRole: optText(200), imageDocumentId: optText(64), reason: optText(2000), confirmationText: z.string().trim().min(1).max(2000), clientId: z.string().min(1).max(64).optional(), }); export type CaptureSignatureInput = z.input; /** Outcomes that count as "signature documented" for the order flow (→ in_review). */ export const SIGNATURE_DOCUMENTED = ["signed", "not_required", "customer_absent", "refused"] as const; /** * Digitale Kundenunterschrift bzw. begründete Ausnahme (Spec §18). * - signed: signer name + PNG image document required * - customer_absent / refused / later: reason required * - not_required: only if the work order does not require a signature or caller has report:approve * A signed signature is never overwritten; other outcomes may be replaced (e.g. "later" → "signed"). */ export async function captureSignature(ctx: ServiceCtx, raw: CaptureSignatureInput): Promise { assertCan(ctx, "report:write"); const input = captureSignatureSchema.parse(raw); if (input.clientId) { const dup = await ctx.db.signature.findFirst({ where: { clientId: input.clientId } }); if (dup) return dup; } const report = await requireVisibleReport(ctx, input.reportId); if (report.status === "approved" || report.status === "superseded") throw new ServiceError("conflict", `report is ${report.status}`); const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { id: true, status: true, signatureRequired: true } }); if (input.outcome === "signed") { if (!input.signerName) throw new ServiceError("invalid", "signer name required", { field: "signerName" }); if (!input.imageDocumentId) throw new ServiceError("invalid", "signature image required", { field: "image" }); const doc = await ctx.db.document.findFirst({ where: { id: input.imageDocumentId, category: "signature", mimeType: "image/png", workOrderId: wo.id, deletedAt: null }, select: { id: true }, }); if (!doc) throw new ServiceError("invalid", "signature image not found", { field: "image" }); } else if (input.imageDocumentId) { throw new ServiceError("invalid", "image only allowed for signed outcome", { field: "image" }); } if (SIGNATURE_REASON_REQUIRED.includes(input.outcome) && !input.reason) { throw new ServiceError("invalid", "reason required", { field: "reason" }); } if (input.outcome === "not_required" && wo.signatureRequired && !can(ctx, "report:approve")) { throw new ServiceError("forbidden", "signature is required for this work order"); } const existing = await ctx.db.signature.findFirst({ where: { reportId: report.id } }); if (existing?.outcome === "signed") throw new ServiceError("conflict", "signature already captured"); const data = { outcome: input.outcome, signerName: input.signerName, signerRole: input.signerRole, imageDocumentId: input.outcome === "signed" ? input.imageDocumentId : null, confirmationText: input.confirmationText, reason: input.reason, signedAt: new Date(), capturedById: ctx.userId, }; const signature = existing ? await ctx.db.signature.update({ where: { id: existing.id }, data }) : await ctx.db.signature.create({ data: { ...data, tenantId: ctx.tenantId, reportId: report.id, clientId: input.clientId ?? null } }); const me = await ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } }); const block: SignatureBlock = { outcome: signature.outcome, signerName: signature.signerName, signerRole: signature.signerRole, signedAt: signature.signedAt.toISOString(), reason: signature.reason, confirmationText: signature.confirmationText, imageDocumentId: signature.imageDocumentId, capturedByName: me?.name ?? null, }; const content = contentOf(report); await ctx.db.report.update({ where: { id: report.id }, data: { content: { ...content, signature: block } } }); const view = (s: Pick | null) => s && { reportId: report.id, outcome: s.outcome, signerName: s.signerName, signerRole: s.signerRole, reason: s.reason, imageDocumentId: s.imageDocumentId }; await auditReport(ctx, existing ? "update" : "create", signature.id, view(existing), view(signature), "signature"); // Signature captured after submit: order waiting for it can move on to review. if ( report.type === "completion" && wo.status === "signature_pending" && (SIGNATURE_DOCUMENTED as readonly string[]).includes(signature.outcome) && can(ctx, "field:execute") ) { await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "in_review", eventData: { occurrenceId: `${report.id}:signature:${signature.id}` } }); } return signature; }