- 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>
114 lines
5.5 KiB
TypeScript
114 lines
5.5 KiB
TypeScript
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<typeof captureSignatureSchema>;
|
|
|
|
/** 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<Signature> {
|
|
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<Signature, "outcome" | "signerName" | "signerRole" | "reason" | "imageDocumentId"> | 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;
|
|
}
|