From 8f53df62081b1ae8e05de117d0d4a614c37329ba Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:22:40 +0200 Subject: [PATCH] L5 Berichte & Unterschrift: Server Actions und API v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/v1/reports/[id]/approve/route.ts | 11 ++ .../reports/[id]/files/[documentId]/route.ts | 9 ++ src/app/api/v1/reports/[id]/pdf/route.ts | 9 ++ .../[id]/completion-report/route.ts | 12 ++ .../v1/work-orders/[id]/daily-report/route.ts | 12 ++ src/server/actions/reports/_state.ts | 32 +++++ src/server/actions/reports/signature.ts | 68 ++++++++++ src/server/actions/reports/workflow.ts | 126 ++++++++++++++++++ 8 files changed, 279 insertions(+) create mode 100644 src/app/api/v1/reports/[id]/approve/route.ts create mode 100644 src/app/api/v1/reports/[id]/files/[documentId]/route.ts create mode 100644 src/app/api/v1/reports/[id]/pdf/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/completion-report/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/daily-report/route.ts create mode 100644 src/server/actions/reports/_state.ts create mode 100644 src/server/actions/reports/signature.ts create mode 100644 src/server/actions/reports/workflow.ts diff --git a/src/app/api/v1/reports/[id]/approve/route.ts b/src/app/api/v1/reports/[id]/approve/route.ts new file mode 100644 index 0000000..cc72288 --- /dev/null +++ b/src/app/api/v1/reports/[id]/approve/route.ts @@ -0,0 +1,11 @@ +import { approveReport } from "@/server/services/reports/approve"; +import { reportDto, withReportsApi } from "@/server/services/reports/http"; + +/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). */ +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withReportsApi(["report:read"], async (ctx) => { + const report = await approveReport(ctx, { reportId: id }); + return Response.json({ report: reportDto(report) }); + }); +} diff --git a/src/app/api/v1/reports/[id]/files/[documentId]/route.ts b/src/app/api/v1/reports/[id]/files/[documentId]/route.ts new file mode 100644 index 0000000..b3cb5b4 --- /dev/null +++ b/src/app/api/v1/reports/[id]/files/[documentId]/route.ts @@ -0,0 +1,9 @@ +import { fileResponse, openReportFile } from "@/server/services/reports/files"; +import { withReportsApi } from "@/server/services/reports/http"; + +/** GET /api/v1/reports/:id/files/:documentId — photo/signature/logo referenced by the report snapshot. */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) { + const { id, documentId } = await params; + const download = new URL(req.url).searchParams.get("download") === "1"; + return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, documentId), { download })); +} diff --git a/src/app/api/v1/reports/[id]/pdf/route.ts b/src/app/api/v1/reports/[id]/pdf/route.ts new file mode 100644 index 0000000..d24183d --- /dev/null +++ b/src/app/api/v1/reports/[id]/pdf/route.ts @@ -0,0 +1,9 @@ +import { fileResponse, openReportFile } from "@/server/services/reports/files"; +import { withReportsApi } from "@/server/services/reports/http"; + +/** GET /api/v1/reports/:id/pdf — the immutable PDF of an approved report (?download=1 for attachment). */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const download = new URL(req.url).searchParams.get("download") === "1"; + return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, "pdf"), { download })); +} diff --git a/src/app/api/v1/work-orders/[id]/completion-report/route.ts b/src/app/api/v1/work-orders/[id]/completion-report/route.ts new file mode 100644 index 0000000..1ee5d45 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/completion-report/route.ts @@ -0,0 +1,12 @@ +import { createCompletionReport } from "@/server/services/reports/create"; +import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; + +/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 + blockers if blocked. */ +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withReportsApi(["report:write"], async (ctx) => { + const body = await readJson(req); + const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); + }); +} diff --git a/src/app/api/v1/work-orders/[id]/daily-report/route.ts b/src/app/api/v1/work-orders/[id]/daily-report/route.ts new file mode 100644 index 0000000..c0bf641 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/daily-report/route.ts @@ -0,0 +1,12 @@ +import { createDailyReport } from "@/server/services/reports/create"; +import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; + +/** POST /api/v1/work-orders/:id/daily-report — create (or return) the daily report draft. Body: { reportDate?, clientId? } */ +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withReportsApi(["report:write"], async (ctx) => { + const body = await readJson(req); + const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); + }); +} diff --git a/src/server/actions/reports/_state.ts b/src/server/actions/reports/_state.ts new file mode 100644 index 0000000..a645ffa --- /dev/null +++ b/src/server/actions/reports/_state.ts @@ -0,0 +1,32 @@ +import { ZodError } from "zod"; +import type { ReportActionState } from "@/lib/reports/action-state"; +import type { CompletionBlocker } from "@/lib/work-orders/status"; +import { ServiceError } from "@/server/services/context"; +import { ForbiddenError } from "@/server/rbac"; +import { ModuleDisabledError } from "@/server/modules"; + +/** Map thrown errors of report actions to a displayable state (no internals leak to the client). */ +export function errorState(err: unknown): ReportActionState { + const at = Date.now(); + if (err instanceof ServiceError) { + const details = err.details as { field?: string } | CompletionBlocker[] | undefined; + return { + status: "error", + code: err.code, + field: details && !Array.isArray(details) ? details.field : undefined, + blockers: Array.isArray(details) ? details : undefined, + at, + }; + } + if (err instanceof ZodError) return { status: "error", code: "invalid", field: String(err.issues[0]?.path[0] ?? ""), at }; + if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return { status: "error", code: "forbidden", at }; + console.error("[actions/reports]", err); + return { status: "error", code: "generic", at }; +} + +export const okState = (reportId?: string): ReportActionState => ({ status: "ok", reportId, at: Date.now() }); + +export const str = (fd: FormData, key: string): string | undefined => { + const v = fd.get(key); + return typeof v === "string" ? v : undefined; +}; diff --git a/src/server/actions/reports/signature.ts b/src/server/actions/reports/signature.ts new file mode 100644 index 0000000..e20cc4a --- /dev/null +++ b/src/server/actions/reports/signature.ts @@ -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 { + 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[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); + } +} diff --git a/src/server/actions/reports/workflow.ts b/src/server/actions/reports/workflow.ts new file mode 100644 index 0000000..3c63667 --- /dev/null +++ b/src/server/actions/reports/workflow.ts @@ -0,0 +1,126 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { REPORT_TEXT_FIELDS, type ReportTexts } from "@/lib/reports/content"; +import type { ReportActionState } from "@/lib/reports/action-state"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { approveReport } from "@/server/services/reports/approve"; +import { createCompletionReport, createDailyReport } from "@/server/services/reports/create"; +import { updateReportTexts } from "@/server/services/reports/edit"; +import { createNewVersion } from "@/server/services/reports/new-version"; +import { rejectReport } from "@/server/services/reports/reject"; +import { submitReport } from "@/server/services/reports/submit"; +import { requireVisibleReport } from "@/server/services/reports/common"; +import { defaultApproveDeps } from "@/server/services/reports/approve"; +import { errorState, okState, str } from "./_state"; + +const guard = moduleGuard("reports"); + +function revalidateReport(reportId: string, workOrderId?: string) { + revalidatePath("/reports"); + revalidatePath(`/reports/${reportId}`); + if (workOrderId) { + revalidatePath(`/m/orders/${workOrderId}/report`); + revalidatePath(`/m/orders/${workOrderId}/sign`); + } +} + +/** Mobile: create daily or completion report draft (form fields: workOrderId, type). */ +export async function createReportAction(_prev: ReportActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("report:write")); + const workOrderId = str(fd, "workOrderId") ?? ""; + const input = { workOrderId, clientId: str(fd, "clientId") || undefined }; + const res = str(fd, "type") === "daily" ? await createDailyReport(ctx, input) : await createCompletionReport(ctx, input); + revalidateReport(res.report.id, workOrderId); + return okState(res.report.id); + } catch (err) { + return errorState(err); + } +} + +/** Mobile: save edited text fields of a draft. */ +export async function saveReportTextsAction(_prev: ReportActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("report:write")); + const texts: Partial = {}; + for (const f of REPORT_TEXT_FIELDS) { + const v = str(fd, f); + if (v !== undefined) texts[f] = v; + } + const report = await updateReportTexts(ctx, { reportId: str(fd, "reportId") ?? "", texts }); + revalidateReport(report.id, report.workOrderId); + return okState(report.id); + } catch (err) { + return errorState(err); + } +} + +/** Mobile: submit report for review (texts in the same form are saved first). */ +export async function submitReportAction(_prev: ReportActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("report:write")); + const reportId = str(fd, "reportId") ?? ""; + const texts: Partial = {}; + for (const f of REPORT_TEXT_FIELDS) { + const v = str(fd, f); + if (v !== undefined) texts[f] = v; + } + if (Object.keys(texts).length) await updateReportTexts(ctx, { reportId, texts }); + const report = await submitReport(ctx, { reportId }); + revalidateReport(report.id, report.workOrderId); + return okState(report.id); + } catch (err) { + return errorState(err); + } +} + +/** Backoffice/team lead: approve (level derived from permissions in the service). */ +export async function approveReportAction(_prev: ReportActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("report:read")); + const report = await approveReport(ctx, { reportId: str(fd, "reportId") ?? "" }); + revalidateReport(report.id, report.workOrderId); + return okState(report.id); + } catch (err) { + return errorState(err); + } +} + +/** Backoffice/team lead: reject with mandatory reason. */ +export async function rejectReportAction(_prev: ReportActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("report:read")); + const report = await rejectReport(ctx, { reportId: str(fd, "reportId") ?? "", reason: str(fd, "reason") ?? "" }); + revalidateReport(report.id, report.workOrderId); + return okState(report.id); + } catch (err) { + return errorState(err); + } +} + +/** Backoffice: new draft version of an approved report. */ +export async function newVersionAction(_prev: ReportActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("report:approve", "report:write")); + const report = await createNewVersion(ctx, { reportId: str(fd, "reportId") ?? "" }); + revalidateReport(report.id, report.workOrderId); + return okState(report.id); + } catch (err) { + return errorState(err); + } +} + +/** Backoffice: re-queue PDF rendering for an approved report without PDF (e.g. failed job). */ +export async function regeneratePdfAction(_prev: ReportActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("report:approve")); + const report = await requireVisibleReport(ctx, str(fd, "reportId") ?? ""); + if (report.status === "approved" && !report.pdfDocumentId) await defaultApproveDeps.dispatchPdf(ctx, report.id); + revalidateReport(report.id); + return okState(report.id); + } catch (err) { + return errorState(err); + } +}