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
@@ -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) });
});
}
@@ -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 }));
}
+9
View File
@@ -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 }));
}
@@ -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<typeof createCompletionReport>[1]);
return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
});
}
@@ -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<typeof createDailyReport>[1]);
return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
});
}
+32
View File
@@ -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;
};
+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);
}
}
+126
View File
@@ -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<ReportActionState> {
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<ReportActionState> {
try {
const ctx = ctxFromGuard(await guard("report:write"));
const texts: Partial<ReportTexts> = {};
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<ReportActionState> {
try {
const ctx = ctxFromGuard(await guard("report:write"));
const reportId = str(fd, "reportId") ?? "";
const texts: Partial<ReportTexts> = {};
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<ReportActionState> {
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<ReportActionState> {
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<ReportActionState> {
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<ReportActionState> {
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);
}
}