From fb993a7730cc8eafc6ce143fe84157bcacf11538 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH 1/9] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20/api?= =?UTF-8?q?/v1=20=C3=BCber=20gemeinsamen=20Adapter,=20einheitliches=20Fehl?= =?UTF-8?q?erformat,=20Rate=20Limiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aufräumpunkt a: Die lane-lokalen API-Kontexte (imports/_context.ts, sync/api-context.ts, reports/http.ts, work-orders/_http.ts mit moduleGuard) sind entfernt. Alle v1-Routen laufen über requireApiContext (DB-autoritative Rechte, 401/403) und withApi/toErrorResponse (respond.ts): - Fehlerformat überall { error: { code, message, details? } }; invalid und blocked → 422, conflict → 409, payload_too_large → 413, rate_limited → 429 + Retry-After. - Same-Origin-Prüfung in withApi für jede Mutation vor der Anmeldung (vorher fehlte sie bei imports, reports und work-orders). - Rate Limiting je Nutzer mit rate-limit.ts: api (API_RATE_LIMIT_PER_MINUTE, 300/min) und apiField für sync/uploads/field (API_FIELD_RATE_LIMIT_PER_MINUTE, 1200/min). - Clients angepasst: Import-Uploader liest das neue Fehlerformat, Upload/Outbox werten 422 als endgültig ungültig (429 bleibt transient mit Backoff). Co-Authored-By: Claude Opus 5 --- src/app/api/v1/field/bundle/route.ts | 20 ++-- src/app/api/v1/field/documents/[id]/route.ts | 33 ++++--- src/app/api/v1/imports/[id]/confirm/route.ts | 23 ++--- src/app/api/v1/imports/[id]/route.ts | 17 ++-- src/app/api/v1/imports/_context.ts | 36 ------- src/app/api/v1/reports/[id]/approve/route.ts | 17 ++-- .../reports/[id]/files/[documentId]/route.ts | 10 +- src/app/api/v1/reports/[id]/pdf/route.ts | 10 +- src/app/api/v1/sync/route.ts | 20 ++-- src/app/api/v1/uploads/route.ts | 45 ++++----- .../api/v1/work-orders/[id]/assign/route.ts | 34 +++---- .../[id]/completion-report/route.ts | 19 ++-- .../v1/work-orders/[id]/daily-report/route.ts | 17 ++-- .../v1/work-orders/[id]/documents/route.ts | 29 +++--- .../v1/work-orders/[id]/materials/route.ts | 34 +++---- src/app/api/v1/work-orders/[id]/route.ts | 39 +++----- .../v1/work-orders/[id]/transition/route.ts | 32 +++---- src/app/api/v1/work-orders/_http.ts | 48 ---------- src/app/api/v1/work-orders/import/route.ts | 39 +++----- src/app/api/v1/work-orders/route.ts | 38 +++----- src/components/imports/uploader.tsx | 6 +- src/lib/field/upload.ts | 2 +- src/lib/offline/outbox.ts | 3 +- src/server/api/context.ts | 31 +++--- src/server/api/respond.ts | 95 ++++++++++++++++--- src/server/rate-limit.ts | 10 ++ src/server/services/reports/dto.ts | 4 + src/server/services/reports/http.ts | 48 ---------- src/server/services/sync/api-context.ts | 57 ----------- 29 files changed, 333 insertions(+), 483 deletions(-) delete mode 100644 src/app/api/v1/imports/_context.ts delete mode 100644 src/app/api/v1/work-orders/_http.ts create mode 100644 src/server/services/reports/dto.ts delete mode 100644 src/server/services/reports/http.ts delete mode 100644 src/server/services/sync/api-context.ts diff --git a/src/app/api/v1/field/bundle/route.ts b/src/app/api/v1/field/bundle/route.ts index 0fe60db..51cd780 100644 --- a/src/app/api/v1/field/bundle/route.ts +++ b/src/app/api/v1/field/bundle/route.ts @@ -1,14 +1,12 @@ -import { NextResponse } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, withApi } from "@/server/api/respond"; import { getFieldBundle } from "@/server/services/field/queries"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** GET /api/v1/field/bundle?since= — offline pull of the orders in scope (ARCHITEKTUR §4.6). */ -export async function GET(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field", "field:execute"); - const raw = new URL(req.url).searchParams.get("since"); - const since = raw ? new Date(raw) : null; - if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since"); - return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); - }); -} +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("field", "field:execute"); + const raw = new URL(req.url).searchParams.get("since"); + const since = raw ? new Date(raw) : null; + if (since && Number.isNaN(since.getTime())) throw new ApiError("invalid", "invalid since"); + return json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); +}); diff --git a/src/app/api/v1/field/documents/[id]/route.ts b/src/app/api/v1/field/documents/[id]/route.ts index 778985b..3a844a2 100644 --- a/src/app/api/v1/field/documents/[id]/route.ts +++ b/src/app/api/v1/field/documents/[id]/route.ts @@ -1,5 +1,6 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; import { openFieldDocument } from "@/server/services/field/documents"; -import { requireApiContext, withApi } from "@/server/services/sync/api-context"; /** * GET /api/v1/field/documents/[?variant=preview] — authorised document delivery for the mobile @@ -8,20 +9,18 @@ import { requireApiContext, withApi } from "@/server/services/sync/api-context"; */ const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/; -export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { - return withApi(req, async () => { - const ctx = await requireApiContext("field"); - const { id } = await params; - const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; - const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); - const safeName = fileName.replace(/["\\\r\n]/g, "_"); - const headers = new Headers({ - "Content-Type": mimeType, - "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, - "X-Content-Type-Options": "nosniff", - "Cache-Control": "private, max-age=300", - }); - if (content.size != null) headers.set("Content-Length", String(content.size)); - return new Response(content.stream, { headers }); +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("field"); + const { id } = await params; + const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; + const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); + const safeName = fileName.replace(/["\\\r\n]/g, "_"); + const headers = new Headers({ + "Content-Type": mimeType, + "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, max-age=300", }); -} + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); +}); diff --git a/src/app/api/v1/imports/[id]/confirm/route.ts b/src/app/api/v1/imports/[id]/confirm/route.ts index 829ff5d..9935007 100644 --- a/src/app/api/v1/imports/[id]/confirm/route.ts +++ b/src/app/api/v1/imports/[id]/confirm/route.ts @@ -1,22 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJson, withApi } from "@/server/api/respond"; import { confirmImport } from "@/server/services/imports/confirm"; -import { apiError, importsApiContext } from "../../_context"; /** * POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts * `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3. */ -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const ctx = await importsApiContext("import:write", "work_order:write"); - const { id } = await params; - let body: unknown; - try { - body = await req.json(); - } catch { - return Response.json({ error: "invalid", message: "json_required" }, { status: 400 }); - } - return Response.json(await confirmImport(ctx, id, body)); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("imports", "import:write", "work_order:write"); + const { id } = await params; + return json(await confirmImport(ctx, id, await readJson(req))); +}); diff --git a/src/app/api/v1/imports/[id]/route.ts b/src/app/api/v1/imports/[id]/route.ts index c35a845..37cdc8d 100644 --- a/src/app/api/v1/imports/[id]/route.ts +++ b/src/app/api/v1/imports/[id]/route.ts @@ -1,13 +1,10 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; import { getImportDetail } from "@/server/services/imports/queries"; -import { apiError, importsApiContext } from "../_context"; /** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */ -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const ctx = await importsApiContext("import:write"); - const { id } = await params; - return Response.json(await getImportDetail(ctx, id)); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("imports", "import:write"); + const { id } = await params; + return json(await getImportDetail(ctx, id)); +}); diff --git a/src/app/api/v1/imports/_context.ts b/src/app/api/v1/imports/_context.ts deleted file mode 100644 index ebdf8a2..0000000 --- a/src/app/api/v1/imports/_context.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { moduleGuard } from "@/server/action-guard"; -import { ForbiddenError, type Permission } from "@/server/rbac"; -import { ModuleDisabledError } from "@/server/modules"; -import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; - -/** - * Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative - * guard as the server actions (session → account/identity status → permissions → module). - * Not a route: files starting with "_" are ignored by the App Router. - * TODO(architecture): replace with a shared `requireApiContext` once it exists. - */ -const guard = moduleGuard("imports"); - -export async function importsApiContext(...permissions: Permission[]): Promise { - return ctxFromGuard(await guard(...permissions)); -} - -const STATUS: Record = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 }; - -/** Map service/guard errors to JSON responses without leaking internals. */ -export function apiError(err: unknown): Response { - if (err instanceof ServiceError) { - return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] }); - } - if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) { - return Response.json({ error: "forbidden" }, { status: 403 }); - } - if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) { - return Response.json({ error: "unauthorized" }, { status: 401 }); - } - if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) { - return Response.json({ error: "forbidden" }, { status: 403 }); - } - console.error("[api/imports]", err); - return Response.json({ error: "internal" }, { status: 500 }); -} diff --git a/src/app/api/v1/reports/[id]/approve/route.ts b/src/app/api/v1/reports/[id]/approve/route.ts index cc72288..cae4fb0 100644 --- a/src/app/api/v1/reports/[id]/approve/route.ts +++ b/src/app/api/v1/reports/[id]/approve/route.ts @@ -1,11 +1,12 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; import { approveReport } from "@/server/services/reports/approve"; -import { reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; -/** 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 }> }) { +/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). Approval rights are checked in the service. */ +export const POST = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); const { id } = await params; - return withReportsApi(["report:read"], async (ctx) => { - const report = await approveReport(ctx, { reportId: id }); - return Response.json({ report: reportDto(report) }); - }); -} + const report = await approveReport(ctx, { reportId: id }); + return 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 index b3cb5b4..d825b64 100644 --- a/src/app/api/v1/reports/[id]/files/[documentId]/route.ts +++ b/src/app/api/v1/reports/[id]/files/[documentId]/route.ts @@ -1,9 +1,11 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; 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 }> }) { +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); 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 })); -} + return 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 index d24183d..9fa9097 100644 --- a/src/app/api/v1/reports/[id]/pdf/route.ts +++ b/src/app/api/v1/reports/[id]/pdf/route.ts @@ -1,9 +1,11 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; 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 }> }) { +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); 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 })); -} + return fileResponse(await openReportFile(ctx, id, "pdf"), { download }); +}); diff --git a/src/app/api/v1/sync/route.ts b/src/app/api/v1/sync/route.ts index 145838a..da4dd6d 100644 --- a/src/app/api/v1/sync/route.ts +++ b/src/app/api/v1/sync/route.ts @@ -1,14 +1,14 @@ -import { NextResponse } from "next/server"; import { syncRequestSchema } from "@/lib/sync/envelope"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readJson, withApi } from "@/server/api/respond"; import { applyOperations } from "@/server/services/sync/apply"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */ -export async function POST(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field"); - const body = syncRequestSchema.safeParse(await req.json().catch(() => null)); - if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10)); - return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } }); - }); -} +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("field"); + const body = syncRequestSchema.safeParse(await readJson(req)); + if (!body.success) { + throw new ApiError("invalid", "invalid sync request", body.error.issues.slice(0, 10).map((i) => ({ path: i.path.join("."), code: i.code }))); + } + return json(await applyOperations(ctx, body.data)); +}); diff --git a/src/app/api/v1/uploads/route.ts b/src/app/api/v1/uploads/route.ts index 0eac1ca..d2a8180 100644 --- a/src/app/api/v1/uploads/route.ts +++ b/src/app/api/v1/uploads/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, withApi } from "@/server/api/respond"; import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** * POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note), @@ -8,27 +8,24 @@ import { apiError, requireApiContext, withApi } from "@/server/services/sync/api */ const MAX_BYTES = 25 * 1024 * 1024; -export async function POST(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field", "field:execute"); - const declared = Number(req.headers.get("content-length") ?? "0"); - if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large"); +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("field", "field:execute"); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (declared > MAX_BYTES + 3 * 1024 * 1024) throw new ApiError("payload_too_large", "file too large"); - const form = await req.formData().catch(() => null); - if (!form) return apiError("invalid", 400, "multipart body expected"); - const file = form.get("file"); - if (!(file instanceof File)) return apiError("invalid", 400, "file missing"); - if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large"); - const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); - if (!meta.success) return apiError("invalid", 400, "invalid upload metadata"); - const preview = form.get("preview"); + const form = await readFormData(req); + const file = form.get("file"); + if (!(file instanceof File)) throw new ApiError("invalid", "file missing"); + if (file.size > MAX_BYTES) throw new ApiError("payload_too_large", "file too large"); + const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); + if (!meta.success) throw new ApiError("invalid", "invalid upload metadata"); + const preview = form.get("preview"); - const result = await storeFieldUpload( - ctx, - meta.data, - { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, - preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, - ); - return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } }); - }); -} + const result = await storeFieldUpload( + ctx, + meta.data, + { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, + preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, + ); + return json(result, { status: result.duplicate ? 200 : 201 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/assign/route.ts b/src/app/api/v1/work-orders/[id]/assign/route.ts index 077d166..262ca1a 100644 --- a/src/app/api/v1/work-orders/[id]/assign/route.ts +++ b/src/app/api/v1/work-orders/[id]/assign/route.ts @@ -1,22 +1,18 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { assignWorkOrder } from "@/server/services/work-orders/assign"; -import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; /** POST /api/v1/work-orders/[id]/assign — body { teamId, userIds?, teamLeadUserId?, baseVersion? }. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params; - const ctx = await apiContext("work_order:assign"); - const body = await readJson(req); - const res = await assignWorkOrder(ctx, { - workOrderId: id, - teamId: String(body.teamId ?? ""), - userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], - teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, - baseVersion: optionalVersion(body.baseVersion), - }); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("work_orders", "work_order:assign"); + const { id } = await params; + const body = await readJsonObject(req); + const res = await assignWorkOrder(ctx, { + workOrderId: id, + teamId: String(body.teamId ?? ""), + userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], + teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return json(res); +}); 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 index 1ee5d45..8199286 100644 --- a/src/app/api/v1/work-orders/[id]/completion-report/route.ts +++ b/src/app/api/v1/work-orders/[id]/completion-report/route.ts @@ -1,12 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createCompletionReport } from "@/server/services/reports/create"; -import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; -/** 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 }> }) { +/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 blocked + blockers if incomplete. */ +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:write"); 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 }); - }); -} + const body = await readJsonObject(req, { allowEmpty: true }); + const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return 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 index c0bf641..44d09e0 100644 --- a/src/app/api/v1/work-orders/[id]/daily-report/route.ts +++ b/src/app/api/v1/work-orders/[id]/daily-report/route.ts @@ -1,12 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createDailyReport } from "@/server/services/reports/create"; -import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; /** 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 }> }) { +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:write"); 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 }); - }); -} + const body = await readJsonObject(req, { allowEmpty: true }); + const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/documents/route.ts b/src/app/api/v1/work-orders/[id]/documents/route.ts index 020ab21..94e10be 100644 --- a/src/app/api/v1/work-orders/[id]/documents/route.ts +++ b/src/app/api/v1/work-orders/[id]/documents/route.ts @@ -1,25 +1,26 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond"; import { ServiceError } from "@/server/services/context"; import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents"; -import { apiContext, apiError } from "../../_http"; /** * POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?). * Used by the backoffice form (HTML post → 303 back to the documents tab) and by API clients (JSON). * A route handler instead of a server action avoids the 1 MB server-action body limit. + * Not wrapped in withApi because browser form posts get redirects instead of JSON errors. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const origin = new URL(req.url).origin; const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html"); try { - // CSRF defence for the cookie-authenticated form post: same-origin only. - const origin = req.headers.get("origin"); - if (origin && origin !== req.nextUrl.origin) throw new ServiceError("forbidden", "cross_origin"); - const ctx = await apiContext("document:write"); - const form = await req.formData(); + assertSameOrigin(req); + const ctx = await requireApiContext("work_orders", "document:write"); + const form = await readFormData(req); const file = form.get("file"); if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing"); + const title = form.get("title"); const doc = await uploadWorkOrderDocument(ctx, { workOrderId: id, bytes: new Uint8Array(await file.arrayBuffer()), @@ -27,14 +28,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: declaredMime: file.type, category: String(form.get("category") ?? "other") as DocumentCategory, visibility: String(form.get("visibility") ?? "team") as DocumentVisibility, - title: typeof form.get("title") === "string" && String(form.get("title")).trim() ? String(form.get("title")).trim() : null, + title: typeof title === "string" && title.trim() ? title.trim() : null, }); - if (wantsHtml) return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, req.nextUrl.origin), 303); - return NextResponse.json(doc, { status: 201 }); + if (wantsHtml) return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, origin), 303); + return json(doc, { status: 201 }); } catch (err) { - if (wantsHtml && err instanceof ServiceError) { - return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, req.nextUrl.origin), 303); + if (wantsHtml && (err instanceof ServiceError || (err instanceof ApiError && err.code !== "unauthorized"))) { + return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, origin), 303); } - return apiError(err); + return toErrorResponse(err); } } diff --git a/src/app/api/v1/work-orders/[id]/materials/route.ts b/src/app/api/v1/work-orders/[id]/materials/route.ts index 72db57e..19bb75f 100644 --- a/src/app/api/v1/work-orders/[id]/materials/route.ts +++ b/src/app/api/v1/work-orders/[id]/materials/route.ts @@ -1,29 +1,21 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { MaterialPlanInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { addMaterialPlan, getMaterialOverview } from "@/server/services/work-orders/materials"; -import { apiContext, apiError, readJson } from "../../_http"; type Params = { params: Promise<{ id: string }> }; /** GET /api/v1/work-orders/[id]/materials — planned vs. actual incl. deviations. */ -export async function GET(_req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - return NextResponse.json({ items: await getMaterialOverview(ctx, id) }); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + return json({ items: await getMaterialOverview(ctx, id) }); +}); /** POST /api/v1/work-orders/[id]/materials — add a material plan item { name, articleNumber?, plannedQuantity, unit, notes? }. */ -export async function POST(req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext("work_order:write"); - const plan = await addMaterialPlan(ctx, id, (await readJson(req)) as MaterialPlanInput); - return NextResponse.json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders", "work_order:write"); + const { id } = await params; + const plan = await addMaterialPlan(ctx, id, (await readJsonObject(req)) as MaterialPlanInput); + return json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/route.ts b/src/app/api/v1/work-orders/[id]/route.ts index dd77e41..c982888 100644 --- a/src/app/api/v1/work-orders/[id]/route.ts +++ b/src/app/api/v1/work-orders/[id]/route.ts @@ -1,34 +1,25 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { computeCompletionBlockers } from "@/server/services/work-orders/completion"; import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; import { updateWorkOrder } from "@/server/services/work-orders/update"; -import { apiContext, apiError, optionalVersion, readJson } from "../_http"; type Params = { params: Promise<{ id: string }> }; /** GET /api/v1/work-orders/[id] — detail incl. transitions available to the caller and completion blockers. */ -export async function GET(_req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - const wo = await getWorkOrderDetail(ctx, id); - const [blockers] = await Promise.all([computeCompletionBlockers(ctx, id)]); - return NextResponse.json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const wo = await getWorkOrderDetail(ctx, id); + const blockers = await computeCompletionBlockers(ctx, id); + return json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); +}); /** PATCH /api/v1/work-orders/[id] — body: partial master data + optional baseVersion (409 on mismatch). */ -export async function PATCH(req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - const { baseVersion, ...patch } = await readJson(req); - const res = await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion)); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const PATCH = withApi(async (req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const { baseVersion, ...patch } = await readJsonObject(req); + return json(await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion))); +}); diff --git a/src/app/api/v1/work-orders/[id]/transition/route.ts b/src/app/api/v1/work-orders/[id]/transition/route.ts index ab0fdf8..038ff70 100644 --- a/src/app/api/v1/work-orders/[id]/transition/route.ts +++ b/src/app/api/v1/work-orders/[id]/transition/route.ts @@ -1,24 +1,20 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { transitionWorkOrder } from "@/server/services/work-orders/transition"; -import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; /** * POST /api/v1/work-orders/[id]/transition — body { to, reason?, baseVersion? }. * 403 forbidden · 404 not in scope · 409 version conflict · 422 invalid / blocked (details: CompletionBlocker[]). */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params; - const ctx = await apiContext(); - const body = await readJson(req); - const res = await transitionWorkOrder(ctx, { - workOrderId: id, - to: String(body.to ?? "") as never, - reason: typeof body.reason === "string" ? body.reason : null, - baseVersion: optionalVersion(body.baseVersion), - }); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const body = await readJsonObject(req); + const res = await transitionWorkOrder(ctx, { + workOrderId: id, + to: String(body.to ?? "") as never, + reason: typeof body.reason === "string" ? body.reason : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return json(res); +}); diff --git a/src/app/api/v1/work-orders/_http.ts b/src/app/api/v1/work-orders/_http.ts deleted file mode 100644 index 76f29f3..0000000 --- a/src/app/api/v1/work-orders/_http.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from "next/server"; -import { moduleGuard } from "@/server/action-guard"; -import { ModuleDisabledError } from "@/server/modules"; -import { ForbiddenError, type Permission } from "@/server/rbac"; -import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; - -/** - * /api/v1/work-orders helpers (lane L2). Authentication/authorisation reuses the DB-authoritative - * moduleGuard (session cookie); the fundament's generic `requireApiContext` does not exist yet — - * see docs/craftvia/lanes/auftraege.md. - */ -export async function apiContext(...permissions: Permission[]): Promise { - const g = await moduleGuard("work_orders")(...permissions); - return ctxFromGuard(g); -} - -const STATUS: Record = { - not_found: 404, - forbidden: 403, - invalid: 422, - conflict: 409, - blocked: 422, -}; - -export function apiError(err: unknown): NextResponse { - if (err instanceof ServiceError) { - return NextResponse.json({ error: { code: err.code, message: err.message, details: err.details ?? null } }, { status: STATUS[err.code] }); - } - if (err instanceof ForbiddenError) return NextResponse.json({ error: { code: "forbidden", message: "forbidden" } }, { status: 403 }); - if (err instanceof ModuleDisabledError) return NextResponse.json({ error: { code: "forbidden", message: "module_disabled" } }, { status: 403 }); - if (err instanceof SyntaxError) return NextResponse.json({ error: { code: "invalid", message: "invalid_json" } }, { status: 400 }); - const msg = err instanceof Error ? err.message : ""; - if (/Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(msg)) { - return NextResponse.json({ error: { code: "unauthorized", message: "unauthorized" } }, { status: 401 }); - } - console.error("[api/v1/work-orders]", err); - return NextResponse.json({ error: { code: "internal", message: "internal" } }, { status: 500 }); -} - -export async function readJson(req: Request): Promise> { - const body = (await req.json()) as unknown; - if (!body || typeof body !== "object" || Array.isArray(body)) throw new ServiceError("invalid", "body_must_be_object"); - return body as Record; -} - -export function optionalVersion(v: unknown): number | undefined { - return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined; -} diff --git a/src/app/api/v1/work-orders/import/route.ts b/src/app/api/v1/work-orders/import/route.ts index 7f63506..34e97dd 100644 --- a/src/app/api/v1/work-orders/import/route.ts +++ b/src/app/api/v1/work-orders/import/route.ts @@ -1,30 +1,21 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, withApi } from "@/server/api/respond"; import { createImport } from "@/server/services/imports/upload"; -import { apiError, importsApiContext } from "../../imports/_context"; /** * POST /api/v1/work-orders/import — multipart upload of an order document (field `file`). * Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background. - * Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report). */ -export async function POST(req: Request) { - try { - const ctx = await importsApiContext("import:write"); - let form: FormData; - try { - form = await req.formData(); - } catch { - return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 }); - } - const file = form.get("file"); - if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 }); - const job = await createImport(ctx, { - bytes: Buffer.from(await file.arrayBuffer()), - fileName: file.name, - mimeType: file.type, - }); - const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } }); - return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("imports", "import:write"); + const form = await readFormData(req); + const file = form.get("file"); + if (!(file instanceof File)) throw new ApiError("invalid", "file_missing"); + const job = await createImport(ctx, { + bytes: Buffer.from(await file.arrayBuffer()), + fileName: file.name, + mimeType: file.type, + }); + const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } }); + return json(current ?? { id: job.id, status: job.status }, { status: 201 }); +}); diff --git a/src/app/api/v1/work-orders/route.ts b/src/app/api/v1/work-orders/route.ts index 9d580f3..8e351dd 100644 --- a/src/app/api/v1/work-orders/route.ts +++ b/src/app/api/v1/work-orders/route.ts @@ -1,30 +1,22 @@ -import { NextResponse, type NextRequest } from "next/server"; import { parseListParams } from "@/lib/work-orders/filters"; import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createWorkOrder } from "@/server/services/work-orders/create"; import { listWorkOrders } from "@/server/services/work-orders/list"; -import { apiContext, apiError, readJson } from "./_http"; /** GET /api/v1/work-orders — filters as in the backoffice list (§21), always within workOrderScope. */ -export async function GET(req: NextRequest) { - try { - const ctx = await apiContext(); - return NextResponse.json(await listWorkOrders(ctx, parseListParams(req.nextUrl.searchParams))); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + return json(await listWorkOrders(ctx, parseListParams(new URL(req.url).searchParams))); +}); -/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). */ -export async function POST(req: NextRequest) { - try { - const ctx = await apiContext(); - const body = await readJson(req); - // Import linkage and number keys are reserved for the import/emergency services. - delete body.sourceImportId; - const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); - return NextResponse.json(created, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). Rights are checked in the service. */ +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + const body = await readJsonObject(req); + // Import linkage and number keys are reserved for the import/emergency services. + delete body.sourceImportId; + const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); + return json(created, { status: 201 }); +}); diff --git a/src/components/imports/uploader.tsx b/src/components/imports/uploader.tsx index 5559291..b190225 100644 --- a/src/components/imports/uploader.tsx +++ b/src/components/imports/uploader.tsx @@ -43,12 +43,14 @@ export function ImportUploader() { if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name }); }; xhr.onload = () => { - const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string }; + // Unified /api/v1 error format: { error: { code, message, details? } } + const res = (xhr.response ?? {}) as { id?: string; error?: { code?: string; message?: string } }; if (xhr.status === 201 && res.id) { setState({ kind: "done", name: file.name, id: res.id }); router.refresh(); } else { - const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error"; + const { code: errCode, message } = res.error ?? {}; + const code = message && KNOWN_ERRORS.has(message) ? message : errCode && KNOWN_ERRORS.has(errCode) ? errCode : "error"; setState({ kind: "error", code }); } if (inputRef.current) inputRef.current.value = ""; diff --git a/src/lib/field/upload.ts b/src/lib/field/upload.ts index a14cd0f..391895d 100644 --- a/src/lib/field/upload.ts +++ b/src/lib/field/upload.ts @@ -40,7 +40,7 @@ export function uploadFieldFile(opts: { } return; } - const error = xhr.status === 400 || xhr.status === 413 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal"; + const error = xhr.status === 400 || xhr.status === 413 || xhr.status === 422 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal"; resolve({ ok: false, error }); }; xhr.send(form); diff --git a/src/lib/offline/outbox.ts b/src/lib/offline/outbox.ts index 6dd9f40..6954390 100644 --- a/src/lib/offline/outbox.ts +++ b/src/lib/offline/outbox.ts @@ -141,7 +141,8 @@ const browserTransport: Transport = { return; } if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" }); - if (xhr.status === 400 || xhr.status === 413) return resolve({ ok: false, error: "invalid" }); + // 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff + if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" }); if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" }); if (xhr.status === 404) return resolve({ ok: false, error: "not_found" }); resolve({ ok: false, error: xhr.status === 0 ? "network" : "internal" }); diff --git a/src/server/api/context.ts b/src/server/api/context.ts index 4fbcce9..ae852fd 100644 --- a/src/server/api/context.ts +++ b/src/server/api/context.ts @@ -7,6 +7,11 @@ import type { Permission } from "@/server/rbac"; import type { ModuleKey } from "@/lib/modules"; import type { ServiceCtx } from "@/server/services/context"; import { ApiError } from "@/server/api/respond"; +import { consumeRateLimit } from "@/server/rate-limit"; + +// assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for +// route handlers that do not use withApi (e.g. /documents/upload with HTML redirects). +export { assertSameOrigin } from "@/server/api/respond"; /** * Service context for /api/v1 route handlers and other route handlers (e.g. /files/). @@ -57,27 +62,23 @@ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissi } } if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403 + if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey); return { db, tenantId, userId: session.user.id, permissions: effective }; } /** - * CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose - * Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in. + * Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts). + * Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous + * `apiField` bucket, everything else `api`. `moduleKey = null` callers (/files downloads, the + * EXEMPT lotse-settings action) are not /api/v1 endpoints and are not counted. + * Exceeded → ApiError `rate_limited` (429 + Retry-After). */ -export function assertSameOrigin(req: Request): void { - const site = req.headers.get("sec-fetch-site"); - if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request"); - const origin = req.headers.get("origin"); - if (origin) { - const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host"); - let originHost: string | null = null; - try { - originHost = new URL(origin).host; - } catch { - originHost = null; - } - if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request"); +export function enforceApiRateLimit(userId: string, moduleKey: ModuleKey): void { + const scope = moduleKey === "field" ? "apiField" : "api"; + const res = consumeRateLimit(scope, userId); + if (!res.allowed) { + throw new ApiError("rate_limited", "too many requests", { retryAfterSeconds: res.retryAfterSeconds }); } } diff --git a/src/server/api/respond.ts b/src/server/api/respond.ts index a4ae630..e65f69e 100644 --- a/src/server/api/respond.ts +++ b/src/server/api/respond.ts @@ -4,10 +4,12 @@ import { ForbiddenError } from "@/server/rbac"; import { ModuleDisabledError } from "@/server/modules"; /** - * JSON response helpers for /api/v1 route handlers (spec §29.2). + * JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter — + * the former lane-local variants in imports/_context.ts, sync/api-context.ts, reports/http.ts and + * work-orders/_http.ts are gone). * Error format: `{ error: { code, message, details? } }`; list format: * `{ data: [...], pagination: { page, pageSize, total } }`. - * Internal error details never leave the server (CWE-209). + * Internal error details never leave the server (CWE-209). Documented in docs/craftvia/API.md. */ export type ApiErrorCode = @@ -18,16 +20,19 @@ export type ApiErrorCode = | "conflict" | "blocked" | "payload_too_large" + | "rate_limited" | "internal"; -const STATUS: Record = { +export const API_ERROR_STATUS: Record = { unauthorized: 401, forbidden: 403, not_found: 404, invalid: 422, conflict: 409, - blocked: 409, + // domain rule prevents the action (e.g. completion blockers) — request itself is well-formed + blocked: 422, payload_too_large: 413, + rate_limited: 429, internal: 500, }; @@ -42,16 +47,22 @@ export class ApiError extends Error { } } -export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response { +export function errorResponse(code: ApiErrorCode, message: string, details?: unknown, headers?: Record): Response { return Response.json( { error: { code, message, ...(details !== undefined ? { details } : {}) } }, - { status: STATUS[code], headers: { "Cache-Control": "no-store" } }, + { status: API_ERROR_STATUS[code], headers: { "Cache-Control": "no-store", ...headers } }, ); } /** Map any thrown error to a JSON error response. */ export function toErrorResponse(err: unknown): Response { - if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details); + if (err instanceof ApiError) { + if (err.code === "rate_limited") { + const retry = (err.details as { retryAfterSeconds?: number } | undefined)?.retryAfterSeconds ?? 60; + return errorResponse(err.code, err.message, err.details, { "Retry-After": String(retry) }); + } + return errorResponse(err.code, err.message, err.details); + } if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details); if (err instanceof ZodError) { return errorResponse( @@ -67,8 +78,8 @@ export function toErrorResponse(err: unknown): Response { return errorResponse("internal", "internal error"); } -export function json(data: unknown, init?: { status?: number }): Response { - return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } }); +export function json(data: unknown, init?: { status?: number; headers?: Record }): Response { + return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store", ...init?.headers } }); } export function paginated(items: T[], total: number, page: number, pageSize: number): Response { @@ -83,10 +94,38 @@ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }): return { page, pageSize }; } -/** Wrap a handler so every thrown error becomes a JSON error response. */ -export function withApi(handler: (...args: A) => Promise) { +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose + * Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in. + * Requests without Origin/Sec-Fetch-Site (server-to-server clients, curl) pass — they carry no + * ambient browser cookies of a victim. + */ +export function assertSameOrigin(req: Request): void { + const site = req.headers.get("sec-fetch-site"); + if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request"); + const origin = req.headers.get("origin"); + if (origin) { + const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host"); + let originHost: string | null = null; + try { + originHost = new URL(origin).host; + } catch { + originHost = null; + } + if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request"); + } +} + +/** + * Wrap a handler: same-origin check for every non-safe method (before authentication, so a + * cross-site request never reaches a service) + every thrown error becomes a JSON error response. + */ +export function withApi(handler: (...args: A) => Promise) { return async (...args: A): Promise => { try { + if (!SAFE_METHODS.has(args[0].method.toUpperCase())) assertSameOrigin(args[0]); return await handler(...args); } catch (err) { return toErrorResponse(err); @@ -102,3 +141,37 @@ export async function readJson(req: Request): Promise { throw new ApiError("invalid", "malformed JSON body"); } } + +/** + * Read a JSON object body. Arrays/primitives → 422. With `allowEmpty` an empty body is `{}` + * (endpoints whose body fields are all optional). + */ +export async function readJsonObject(req: Request, opts: { allowEmpty?: boolean } = {}): Promise> { + const text = await req.text(); + if (!text.trim()) { + if (opts.allowEmpty) return {}; + throw new ApiError("invalid", "JSON body required"); + } + let body: unknown; + try { + body = JSON.parse(text); + } catch { + throw new ApiError("invalid", "malformed JSON body"); + } + if (!body || typeof body !== "object" || Array.isArray(body)) throw new ApiError("invalid", "JSON object expected"); + return body as Record; +} + +/** Read a multipart body; anything else → 422. */ +export async function readFormData(req: Request): Promise { + try { + return await req.formData(); + } catch { + throw new ApiError("invalid", "multipart body expected"); + } +} + +/** Optimistic-locking version from a body field (positive integer), otherwise undefined. */ +export function optionalVersion(v: unknown): number | undefined { + return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined; +} diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts index 0b82897..03775c7 100644 --- a/src/server/rate-limit.ts +++ b/src/server/rate-limit.ts @@ -40,6 +40,12 @@ function keyOf(scope: string, identifier: string): string { export type RateLimitRule = { limit: number; windowMs: number }; +/** Positive integer from the environment, otherwise the default (read once at module load). */ +function perMinute(name: string, fallback: number): number { + const v = Number(process.env[name]); + return Number.isInteger(v) && v > 0 ? v : fallback; +} + /** Voreinstellungen der sicherheitskritischen Aktionen (SEC2 §6). */ export const RATE_LIMITS = { /** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */ @@ -50,6 +56,10 @@ export const RATE_LIMITS = { passwordVerify: { limit: 10, windowMs: 15 * 60_000 }, /** Anforderung einer E-Mail-Änderung. */ emailChangeRequest: { limit: 5, windowMs: 60 * 60_000 }, + /** L10b: /api/v1/** je Nutzer (Integrationen, Backoffice-Clients). */ + api: { limit: perMinute("API_RATE_LIMIT_PER_MINUTE", 300), windowMs: 60_000 }, + /** L10b: Einsatz-/Sync-Endpunkte (/sync, /uploads, /field/**) je Nutzer — großzügig (Outbox, Vorab-Download). */ + apiField: { limit: perMinute("API_FIELD_RATE_LIMIT_PER_MINUTE", 1200), windowMs: 60_000 }, } as const satisfies Record; export type RateLimitScope = keyof typeof RATE_LIMITS; diff --git a/src/server/services/reports/dto.ts b/src/server/services/reports/dto.ts new file mode 100644 index 0000000..e7085ee --- /dev/null +++ b/src/server/services/reports/dto.ts @@ -0,0 +1,4 @@ +/** Public shape of a report in /api/v1 responses (no snapshot content). */ +export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) { + return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) }; +} diff --git a/src/server/services/reports/http.ts b/src/server/services/reports/http.ts deleted file mode 100644 index 073ffae..0000000 --- a/src/server/services/reports/http.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ZodError } from "zod"; -import { moduleGuard } from "@/server/action-guard"; -import { ModuleDisabledError } from "@/server/modules"; -import { ForbiddenError, type Permission } from "@/server/rbac"; -import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; - -/** - * Thin adapter for /api/v1 report route handlers: module gate + DB-authoritative permissions → ServiceCtx, - * uniform JSON error mapping. (No shared requireApiContext exists yet; replace at merge if the architect adds one.) - */ -const guard = moduleGuard("reports"); - -const STATUS: Record = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 422 }; - -export function apiError(err: unknown): Response { - if (err instanceof ServiceError) return Response.json({ error: err.code, details: err.details ?? null }, { status: STATUS[err.code] }); - if (err instanceof ZodError) return Response.json({ error: "invalid", details: err.issues.map((i) => ({ path: i.path.join("."), code: i.code })) }, { status: 400 }); - if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return Response.json({ error: "forbidden" }, { status: 403 }); - if (err instanceof Error && /Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(err.message)) { - return Response.json({ error: "unauthorized" }, { status: 401 }); - } - console.error("[api/reports]", err); - return Response.json({ error: "internal" }, { status: 500 }); -} - -export async function withReportsApi(permissions: Permission[], handler: (ctx: ServiceCtx) => Promise): Promise { - try { - const g = await guard(...permissions); - return await handler(ctxFromGuard(g)); - } catch (err) { - return apiError(err); - } -} - -export async function readJson(req: Request): Promise> { - const text = await req.text(); - if (!text.trim()) return {}; - try { - const v = JSON.parse(text); - return v && typeof v === "object" && !Array.isArray(v) ? v : {}; - } catch { - throw new ServiceError("invalid", "body must be JSON"); - } -} - -export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) { - return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) }; -} diff --git a/src/server/services/sync/api-context.ts b/src/server/services/sync/api-context.ts deleted file mode 100644 index f04c4fe..0000000 --- a/src/server/services/sync/api-context.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextResponse } from "next/server"; -import { moduleGuard } from "@/server/action-guard"; -import { ForbiddenError, type Permission } from "@/server/rbac"; -import { ModuleDisabledError } from "@/server/modules"; -import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; -import type { ModuleKey } from "@/lib/modules"; - -/** - * Context for /api/v1 route handlers (lane L4: sync, uploads, field). Reuses moduleGuard, so - * route handlers get exactly the same DB-authoritative checks as server actions (session, account - * status, kill switch, password change, permissions, module enabled). - * NOTE for the architect: a shared `requireApiContext` is referenced in services/context.ts but not - * provided by the foundation — this is the lane-local implementation. - */ -export async function requireApiContext(moduleKey: ModuleKey, ...permissions: Permission[]): Promise { - return ctxFromGuard(await moduleGuard(moduleKey)(...permissions)); -} - -const STATUS_FOR: Record = { - not_found: 404, - forbidden: 403, - invalid: 400, - conflict: 409, - blocked: 422, -}; - -export function apiError(code: string, status: number, message?: string, details?: unknown) { - return NextResponse.json({ error: code, ...(message ? { message } : {}), ...(details !== undefined ? { details } : {}) }, { status, headers: { "Cache-Control": "no-store" } }); -} - -/** Wraps a handler: same-origin check for mutations + uniform error mapping (no internals leaked). */ -export async function withApi(req: Request, fn: () => Promise): Promise { - if (req.method !== "GET" && req.method !== "HEAD") { - const origin = req.headers.get("origin"); - const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host"); - if (origin && host) { - let originHost: string | null = null; - try { - originHost = new URL(origin).host; - } catch { - originHost = null; - } - if (originHost !== host) return apiError("forbidden", 403, "cross-origin request"); - } - } - try { - return await fn(); - } catch (err) { - if (err instanceof ServiceError) return apiError(err.code, STATUS_FOR[err.code], err.message, err.code === "blocked" ? err.details : undefined); - if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return apiError("forbidden", 403); - const msg = err instanceof Error ? err.message : ""; - if (/Nicht angemeldet|nicht mehr gueltig/.test(msg)) return apiError("unauthorized", 401); - if (/Konto ist nicht aktiv|Passwortwechsel erforderlich/.test(msg)) return apiError("forbidden", 403); - console.error("[api/v1] unhandled error:", err); - return apiError("internal", 500); - } -} From 49f05c8db3639c17add4ab0263f2fefbea68c0a0 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH 2/9] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20Open?= =?UTF-8?q?API=203.1=20unter=20/api/v1/openapi.json,=20API-Doku=20und=20AP?= =?UTF-8?q?I-Test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/api/openapi.ts: statisch gepflegte Spezifikation aller v1-Routen inkl. Fehlerformat, Pagination, Idempotenz (clientOpId/clientId), Konflikte, Rate Limits, Rechte je Operation. - GET /api/v1/openapi.json liefert das Dokument (angemeldete Nutzer). - docs/craftvia/API.md: Kurzdoku mit Endpunkt-Tabelle. - scripts/test-betrieb-api.ts: jede Route nutzt requireApiContext/respond.ts, 401 ohne Sitzung im einheitlichen Format, 403 bei fremdem Origin/Sec-Fetch-Site, Fehler-Mapping, Rate Limit je Nutzer (Standard/Einsatz getrennt), OpenAPI deckt jede route.ts ab. Co-Authored-By: Claude Opus 5 --- docs/craftvia/API.md | 94 +++ scripts/test-betrieb-api.ts | 178 ++++ src/app/api/v1/openapi.json/route.ts | 12 + src/lib/api/openapi.ts | 1135 ++++++++++++++++++++++++++ 4 files changed, 1419 insertions(+) create mode 100644 docs/craftvia/API.md create mode 100644 scripts/test-betrieb-api.ts create mode 100644 src/app/api/v1/openapi.json/route.ts create mode 100644 src/lib/api/openapi.ts diff --git a/docs/craftvia/API.md b/docs/craftvia/API.md new file mode 100644 index 0000000..02b2096 --- /dev/null +++ b/docs/craftvia/API.md @@ -0,0 +1,94 @@ +# Craftvia API (`/api/v1`) + +Versionierte JSON-API für Backoffice-Formulare, die Mobile-App/PWA (Offline-Sync) und künftige Integrationen. Die maschinenlesbare Spezifikation (OpenAPI 3.1) liefert `GET /api/v1/openapi.json` (gepflegt in `src/lib/api/openapi.ts`, `API_ROUTES` listet alle dokumentierten Pfade). Jede neue oder geänderte `src/app/api/v1/**/route.ts` muss dort nachgetragen werden. + +## Authentifizierung und CSRF + +- **Session-Cookie** von Auth.js: `authjs.session-token` (unter HTTPS `__Secure-authjs.session-token`). Ohne Cookie antwortet bereits der Proxy (`src/proxy.ts`) mit `401`. +- **Rechte** werden bei jedem Request aus der Datenbank gelesen (Mitgliedschaft, Identitätsstatus, Session-Kill-Switch, Passwortwechsel, effektive Rechte), nie aus dem JWT. Fehlt ein Recht oder ist das Modul des Mandanten deaktiviert, kommt `403`. +- **Sichtbarkeit:** Objekte eines fremden Mandanten oder außerhalb des eigenen Scopes (z. B. Monteur ↔ fremder Auftrag) liefern `404`, nicht `403`. +- **CSRF:** Schreibende Methoden (POST/PATCH) nur Same-Origin: Der `Origin`-Header muss zum Host passen, `Sec-Fetch-Site` muss `same-origin` oder `none` sein. Sonst `403 forbidden`. + +## Fehlerformat + +Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und + +```json +{ "error": { "code": "invalid", "message": "validation failed", "details": [{ "path": "customerId", "code": "too_small" }] } } +``` + +| Code | HTTP | Bedeutung / `details` | +|---|---|---| +| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert | +| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request | +| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes | +| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) | +| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart | +| `blocked` | 422 | fachlich gesperrt, z. B. `details = CompletionBlocker[]` | +| `payload_too_large` | 413 | Datei/Body zu groß | +| `rate_limited` | 429 | Header `Retry-After` (Sekunden), `details.retryAfterSeconds` | +| `internal` | 500 | unerwarteter Fehler, keine internen Details | + +## Pagination + +`GET /customers`, `GET /sites` und `GET /sites/{id}/history` verwenden `?page` (≥ 1) und `?pageSize` (1–100, Standard 25, bei der Historie 50). Antwort: `{ "data": [...], "pagination": { "page", "pageSize", "total" } }` (Historie zusätzlich `meta.onlyApproved`). +`GET /work-orders` hat ein eigenes Format: `{ items, total, page, pageSize, groupCounts }` (`groupCounts` = Anzahl je Statusgruppe ohne Status-/Gruppenfilter). + +## Idempotenz und Konflikte (Sync) + +- `POST /sync` nimmt `{ deviceId, operations[] }` mit 1–100 Operationen an (die PWA-Outbox schickt Batches ≤ 50). Jede Operation hat eine `clientOpId` (UUID) und wird einzeln angewendet. Die HTTP-Antwort ist `200`, das Ergebnis steht je Operation in `results[]`: `applied` | `duplicate` | `conflict` | `rejected` (mit `errorCode`, `message`, `idMap`, `entityVersion`). +- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`. +- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet). +- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`). +- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`. + +## Uploads + +- `POST /uploads` (Einsatz): multipart mit `file`, `clientId` (UUID), `workOrderId`, `kind` (`photo` | `voice_note`) und optional `preview` (Thumbnail ≤ 2 MB). Maximal 25 MB, der Inhalt wird per Magic Bytes geprüft. Idempotent über `clientId`: dieselbe clientId liefert `200 { documentId, duplicate: true }`, ein neuer Upload `201 { documentId, duplicate: false }`. Die `documentId` wird danach in `photo.attach`/`voice.attach` referenziert. +- `POST /work-orders/{id}/documents`: multipart mit `file`, `category`, `visibility`, `title?`. Antwort `201`. Mit `Accept: text/html` kommt stattdessen ein `303`-Redirect (Backoffice-Formular). +- `POST /work-orders/import`: multipart mit `file` (PDF/JPEG/PNG, ≤ 25 MB), Antwort `201 { id, status }`. Die Extraktion läuft asynchron. + +## Rate Limits + +Die Zählung erfolgt je Nutzer in einem Fenster von einer Minute, im Speicher je App-Instanz (bei mehreren Instanzen also pro Instanz). + +- Standard: `API_RATE_LIMIT_PER_MINUTE` (Default 300) +- Einsatz-Endpunkte `/sync`, `/uploads`, `/field/**`: `API_FIELD_RATE_LIMIT_PER_MINUTE` (Default 1200) + +Bei Überschreitung kommt `429` mit `Retry-After`. + +## Endpunkte + +Die Pfade sind relativ zu `/api/v1`. „Recht“ nennt das Gate der Route. Mit „Service“ markierte Rechte prüft der Service (zusätzlich zum Scope). + +| Methode | Pfad | Modul | Recht | Beschreibung | +|---|---|---|---|---| +| GET | `/customers` | customers | `customer:read` | Kunden suchen (`q`, `status`, paginiert) | +| POST | `/customers` | customers | `customer:write` | Kunde anlegen (409 bei möglichen Dubletten ohne `acknowledgeDuplicates`) | +| GET | `/customers/{id}` | customers | `customer:read` | Kunde inkl. Ansprechpartner | +| PATCH | `/customers/{id}` | customers | `customer:write` | Kunde ändern (fehlt = unverändert, `null` = leeren) | +| GET | `/sites` | sites | `site:read` | Standorte suchen (`q`, `customerId`, `status`, paginiert) | +| POST | `/sites` | sites | `site:write` | Standort anlegen | +| GET | `/sites/{id}/history` | sites | `site:read` | Einsatzhistorie (Außendienst: nur freigegebene Einsätze) | +| GET | `/work-orders` | work_orders | Scope (`work_order:read_all`/`read_team`) | Auftragsliste mit Filtern/Presets | +| POST | `/work-orders` | work_orders | Service: `work_order:write` (Notfall: `emergency:create`) | Auftrag anlegen | +| GET | `/work-orders/{id}` | work_orders | Scope | Detail + `availableTransitions` + `completionBlockers` | +| PATCH | `/work-orders/{id}` | work_orders | Service: `work_order:write` | Stammdaten ändern (`baseVersion`) | +| POST | `/work-orders/{id}/assign` | work_orders | `work_order:assign` | Team/Monteure zuweisen | +| POST | `/work-orders/{id}/transition` | work_orders | je Übergang (`requiredPermission`) | Statuswechsel (422 `blocked` mit Blockern) | +| GET | `/work-orders/{id}/materials` | work_orders | Scope | Material Soll/Ist | +| POST | `/work-orders/{id}/materials` | work_orders | `work_order:write` | Materialvorgabe hinzufügen | +| POST | `/work-orders/{id}/documents` | work_orders | `document:write` | Dokument hochladen (multipart) | +| POST | `/work-orders/{id}/daily-report` | reports | `report:write` | Tagesbericht-Entwurf anlegen/holen (201/200) | +| POST | `/work-orders/{id}/completion-report` | reports | `report:write` | Abschlussbericht-Entwurf anlegen/holen (422 bei Blockern) | +| POST | `/work-orders/import` | imports | `import:write` | Auftragsdokument importieren (multipart) | +| GET | `/imports/{id}` | imports | `import:write` | Importstatus, Extraktion, Kandidaten | +| POST | `/imports/{id}/confirm` | imports | `import:write`, `work_order:write` | Prüfformular bestätigen → Auftrag | +| POST | `/reports/{id}/approve` | reports | `report:read` + Service: `report:approve_team`/`report:approve` | Bericht freigeben | +| GET | `/reports/{id}/pdf` | reports | `report:read` | PDF des freigegebenen Berichts (`?download=1`) | +| GET | `/reports/{id}/files/{documentId}` | reports | `report:read` | Foto/Unterschrift/Logo aus dem Bericht | +| POST | `/sync` | field | Service je opType (`field:execute`, `emergency:create`, …) | Batch-Operationen (offline/online) | +| POST | `/uploads` | field | `field:execute` | Foto/Sprachnotiz hochladen → `documentId` | +| GET | `/field/bundle` | field | `field:execute` | Offline-Pull (`?since=`, max. 200 Aufträge) | +| GET | `/field/documents/{id}` | field | Service: `document:read` + Sichtbarkeit/Scope | Dokument für die Mobile-App (`?variant=preview`) | +| GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument | diff --git a/scripts/test-betrieb-api.ts b/scripts/test-betrieb-api.ts new file mode 100644 index 0000000..ec575f1 --- /dev/null +++ b/scripts/test-betrieb-api.ts @@ -0,0 +1,178 @@ +// Lane L10b „Betrieb & Aufräumen" — /api/v1 vereinheitlicht (Aufräumpunkt a) + Rate Limiting: +// Jede Route läuft über requireApiContext + withApi (respond.ts): einheitliches Fehlerformat +// { error: { code, message, details? } }, Statuscodes je Code, Same-Origin-Prüfung für jede +// Mutation (vor der Authentifizierung), 401 ohne Sitzung, 429 + Retry-After beim Rate Limit. +// +// Lauf: npx tsx scripts/test-betrieb-api.ts (keine DB-Schreibzugriffe) + +import "dotenv/config"; +// Kleine Limits für den Test — rate-limit.ts liest die Env beim Laden des Moduls. +process.env.API_RATE_LIMIT_PER_MINUTE = "5"; +process.env.API_FIELD_RATE_LIMIT_PER_MINUTE = "12"; + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { z } from "zod"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const ROOT = join(process.cwd(), "src/app/api/v1"); +const HOST = "localhost:3111"; +const METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const; +const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]); + +function routeFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const p = join(dir, name); + if (statSync(p).isDirectory()) return routeFiles(p); + return name === "route.ts" ? [p] : []; + }); +} + +/** `src/app/api/v1/work-orders/[id]/route.ts` → `/api/v1/work-orders/{id}` */ +function apiPath(file: string): string { + const rel = relative(ROOT, file).replace(/\/?route\.ts$/, ""); + return `/api/v1${rel ? `/${rel}` : ""}`.replace(/\[([^\]]+)\]/g, "{$1}"); +} + +type Handler = (req: Request, ctx: { params: Promise> }) => Promise; + +async function errorBody(res: Response): Promise<{ code?: string; message?: string } | null> { + try { + const body = (await res.json()) as { error?: { code?: string; message?: string } }; + return body.error && typeof body.error === "object" ? body.error : null; + } catch { + return null; + } +} + +async function main() { + const { ServiceError } = await import("../src/server/services/context"); + const { ApiError, API_ERROR_STATUS, readJsonObject, toErrorResponse } = await import("../src/server/api/respond"); + const { enforceApiRateLimit } = await import("../src/server/api/context"); + const { resetRateLimits } = await import("../src/server/rate-limit"); + + const files = routeFiles(ROOT).sort(); + const apiFiles = files.filter((f) => !f.includes("openapi.json")); + ok(apiFiles.length >= 22, `alle v1-Routen gefunden (${apiFiles.length})`); + + console.log("\n— OpenAPI deckt jede Route ab —"); + const { API_ROUTES, openApiDocument } = await import("../src/lib/api/openapi"); + const documented = new Set(API_ROUTES); + for (const file of files) ok(documented.has(apiPath(file)), `OpenAPI dokumentiert ${apiPath(file)}`); + ok(documented.size === files.length, `keine veralteten OpenAPI-Pfade (${documented.size} dokumentiert, ${files.length} Routen)`); + const doc = openApiDocument as { openapi?: string; paths?: Record> }; + ok(typeof doc.openapi === "string" && doc.openapi.startsWith("3.1"), "OpenAPI 3.1"); + const specRes = await ((await import(pathToFileURL(join(ROOT, "openapi.json/route.ts")).href)) as { GET: () => Promise }).GET(); + const spec = (await specRes.json()) as { paths?: Record }; + ok(specRes.status === 200 && Object.keys(spec.paths ?? {}).length === files.length, "GET /api/v1/openapi.json liefert das Dokument"); + + console.log("\n— Statisch: ein gemeinsamer Adapter —"); + for (const file of apiFiles) { + const src = readFileSync(file, "utf8"); + const path = apiPath(file); + ok(src.includes("requireApiContext(") && !/moduleGuard|action-guard|_context|api-context|reports\/http|_http/.test(src), `${path}: requireApiContext, keine lane-lokalen Kontexte`); + ok(/withApi\(|toErrorResponse\(/.test(src), `${path}: Fehler über respond.ts`); + } + + console.log("\n— Ohne Sitzung: 401 im einheitlichen Format —"); + const params = Promise.resolve({ id: "zz-unknown", documentId: "zz-unknown" }); + for (const file of apiFiles) { + const mod = (await import(pathToFileURL(file).href)) as Record; + const path = apiPath(file).replace(/\{[^}]+\}/g, "zz-unknown"); + for (const method of METHODS) { + const handler = mod[method] as Handler | undefined; + if (typeof handler !== "function") continue; + const headers: Record = { host: HOST, accept: "application/json" }; + const init: RequestInit = { method, headers }; + if (MUTATING.has(method)) { + headers.origin = `http://${HOST}`; + headers["sec-fetch-site"] = "same-origin"; + headers["content-type"] = "application/json"; + init.body = "{}"; + } + const res = await handler(new Request(`http://${HOST}${path}`, init), { params }); + const err = await errorBody(res); + ok(res.status === 401 && err?.code === "unauthorized" && res.headers.get("cache-control") === "no-store", `${method} ${apiPath(file)} ohne Sitzung → 401 unauthorized`); + + if (MUTATING.has(method)) { + const cross = await handler( + new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, origin: "https://evil.example", "content-type": "application/json" }, body: "{}" }), + { params }, + ); + const crossErr = await errorBody(cross); + ok(cross.status === 403 && crossErr?.code === "forbidden", `${method} ${apiPath(file)} fremder Origin → 403 (vor der Anmeldung)`); + const site = await handler( + new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, "sec-fetch-site": "cross-site", "content-type": "application/json" }, body: "{}" }), + { params }, + ); + ok(site.status === 403, `${method} ${apiPath(file)} Sec-Fetch-Site cross-site → 403`); + } + } + } + + console.log("\n— Fehler-Mapping (respond.ts) —"); + const expected: Record = { not_found: 404, forbidden: 403, invalid: 422, conflict: 409, blocked: 422 }; + for (const [code, status] of Object.entries(expected)) { + const res = toErrorResponse(new ServiceError(code as "not_found", `msg ${code}`, code === "blocked" ? [{ kind: "checklist_item", id: "c1" }] : undefined)); + const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } }; + ok(res.status === status && body.error.code === code && body.error.message === `msg ${code}`, `ServiceError ${code} → ${status}`); + if (code === "blocked") ok(Array.isArray(body.error.details), "blocked → details (CompletionBlocker[])"); + } + const zodErr = z.object({ name: z.string() }).safeParse({ name: 1 }); + const zres = toErrorResponse(zodErr.error); + const zbody = (await zres.json()) as { error: { code: string; details: { path: string }[] } }; + ok(zres.status === 422 && zbody.error.code === "invalid" && zbody.error.details[0]?.path === "name", "ZodError → 422 invalid mit Feldpfaden"); + const origError = console.error; + console.error = () => {}; + const internal = toErrorResponse(new Error("SELECT secret FROM users")); + console.error = origError; + const ibody = await internal.text(); + ok(internal.status === 500 && !ibody.includes("secret"), "unbekannter Fehler → 500 ohne interne Details"); + ok(API_ERROR_STATUS.rate_limited === 429 && API_ERROR_STATUS.payload_too_large === 413 && API_ERROR_STATUS.unauthorized === 401, "Statuscodes 429/413/401"); + + const req = (body: string) => new Request(`http://${HOST}/x`, { method: "POST", body }); + const code = async (p: Promise) => p.then(() => "ok", (e: { code?: string }) => e.code ?? "error"); + ok(JSON.stringify(await readJsonObject(req(""), { allowEmpty: true })) === "{}", "readJsonObject: leerer Body mit allowEmpty → {}"); + ok((await code(readJsonObject(req("")))) === "invalid", "readJsonObject: leerer Body → invalid"); + ok((await code(readJsonObject(req("[1]")))) === "invalid", "readJsonObject: Array → invalid"); + ok((await code(readJsonObject(req("{nope")))) === "invalid", "readJsonObject: kaputtes JSON → invalid"); + + console.log("\n— Rate Limiting je Nutzer —"); + resetRateLimits(); + const hit = (user: string, moduleKey: "customers" | "field") => { + try { + enforceApiRateLimit(user, moduleKey); + return null; + } catch (err) { + return err as InstanceType; + } + }; + let firstBlocked = -1; + for (let i = 1; i <= 6; i++) if (hit("zz-user-a", "customers") && firstBlocked < 0) firstBlocked = i; + ok(firstBlocked === 6, "Standard-Bucket: 5 Anfragen erlaubt, die 6. abgelehnt"); + const blocked = hit("zz-user-a", "customers"); + ok(blocked?.code === "rate_limited" && ((blocked.details as { retryAfterSeconds: number }).retryAfterSeconds ?? 0) > 0, "Ablehnung als rate_limited mit retryAfterSeconds"); + const res429 = toErrorResponse(blocked); + ok(res429.status === 429 && Number(res429.headers.get("retry-after")) > 0, "429 mit Retry-After-Header"); + ok(hit("zz-user-b", "customers") === null, "anderer Nutzer hat eigenes Kontingent"); + let fieldBlocked = -1; + for (let i = 1; i <= 13; i++) if (hit("zz-user-a", "field") && fieldBlocked < 0) fieldBlocked = i; + ok(fieldBlocked === 13, "Einsatz-Bucket (sync/uploads/field) getrennt und großzügiger: 12 erlaubt, 13. abgelehnt"); + resetRateLimits(); +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(() => { + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle API-Prüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..10ded33 --- /dev/null +++ b/src/app/api/v1/openapi.json/route.ts @@ -0,0 +1,12 @@ +import { openApiDocument } from "@/lib/api/openapi"; + +/** + * GET /api/v1/openapi.json — the statically maintained OpenAPI 3.1 document (src/lib/api/openapi.ts). + * + * Auth: src/proxy.ts rejects every /api/v1 request without a session cookie with 401, so the + * document is only reachable for signed-in users. It contains no tenant data, therefore no + * further permission/module check is done here (deliberately — any API client may read it). + */ +export function GET() { + return Response.json(openApiDocument, { headers: { "Cache-Control": "private, max-age=300" } }); +} diff --git a/src/lib/api/openapi.ts b/src/lib/api/openapi.ts new file mode 100644 index 0000000..1730fae --- /dev/null +++ b/src/lib/api/openapi.ts @@ -0,0 +1,1135 @@ +/** + * Statically maintained OpenAPI 3.1 description of every route handler under + * src/app/api/v1/** (served by GET /api/v1/openapi.json, human summary in docs/craftvia/API.md). + * + * Maintenance rule: a new/changed route.ts must be reflected here — `API_ROUTES` lists the + * documented paths (full `/api/v1` prefix, `{param}` = `[param]` folder) so a test can compare + * them with the file system. Schemas describe the core fields read from the services/Zod + * schemas; entity objects stay open (`additionalProperties: true`) where Prisma rows are returned. + * Client-safe: no server imports. + */ + +type Schema = Record; + +// ---------- small builders ---------- + +const ref = (name: string): Schema => ({ $ref: `#/components/schemas/${name}` }); +const str = (extra: Schema = {}): Schema => ({ type: "string", ...extra }); +const nstr = (extra: Schema = {}): Schema => ({ type: ["string", "null"], ...extra }); +const int = (extra: Schema = {}): Schema => ({ type: "integer", ...extra }); +const num = (extra: Schema = {}): Schema => ({ type: "number", ...extra }); +const bool = (extra: Schema = {}): Schema => ({ type: "boolean", ...extra }); +const dateTime = (extra: Schema = {}): Schema => ({ type: "string", format: "date-time", ...extra }); +const nDateTime = (extra: Schema = {}): Schema => ({ type: ["string", "null"], format: "date-time", ...extra }); +const arr = (items: Schema, extra: Schema = {}): Schema => ({ type: "array", items, ...extra }); +const obj = (properties: Record, required: string[] = [], extra: Schema = {}): Schema => ({ + type: "object", + properties, + ...(required.length ? { required } : {}), + ...extra, +}); +const open = (properties: Record, required: string[] = []): Schema => obj(properties, required, { additionalProperties: true }); + +const jsonBody = (schema: Schema, required = true): Schema => ({ required, content: { "application/json": { schema } } }); +const jsonResponse = (description: string, schema: Schema, headers?: Schema): Schema => ({ + description, + ...(headers ? { headers } : {}), + content: { "application/json": { schema } }, +}); +const binaryResponse = (description: string): Schema => ({ + description, + headers: { + "Content-Disposition": { schema: str(), description: "`inline` bzw. `attachment; filename=\"…\"`" }, + "X-Content-Type-Options": { schema: str({ const: "nosniff" }) }, + }, + content: { "application/octet-stream": { schema: str({ contentMediaType: "application/octet-stream" }) } }, +}); + +const pathParam = (name: string, description: string): Schema => ({ name, in: "path", required: true, schema: str({ maxLength: 64 }), description }); +const query = (name: string, schema: Schema, description?: string): Schema => ({ name, in: "query", required: false, schema, ...(description ? { description } : {}) }); + +type ErrorKey = "unauthorized" | "forbidden" | "not_found" | "conflict" | "unprocessable" | "payload_too_large" | "rate_limited" | "internal"; +const ERROR_RESPONSES: Record = { + unauthorized: ["401", "Unauthorized"], + forbidden: ["403", "Forbidden"], + not_found: ["404", "NotFound"], + conflict: ["409", "Conflict"], + unprocessable: ["422", "Unprocessable"], + payload_too_large: ["413", "PayloadTooLarge"], + rate_limited: ["429", "RateLimited"], + internal: ["500", "Internal"], +}; +/** Standard error responses (401/403/429/500 always) plus the given extras. */ +function errors(...extra: ErrorKey[]): Record { + const keys: ErrorKey[] = ["unauthorized", "forbidden", ...extra, "rate_limited", "internal"]; + const out: Record = {}; + for (const k of keys) { + const [status, name] = ERROR_RESPONSES[k]; + out[status] = { $ref: `#/components/responses/${name}` }; + } + return out; +} + +type Op = { + tag: string; + summary: string; + description?: string; + operationId: string; + /** `x-craftvia-module` / `x-craftvia-permissions` document the route-level gate. */ + module: string | null; + permissions: string[]; + parameters?: Schema[]; + requestBody?: Schema; + responses: Record; + security?: Schema[]; +}; +const op = (o: Op): Schema => ({ + tags: [o.tag], + summary: o.summary, + ...(o.description ? { description: o.description } : {}), + operationId: o.operationId, + "x-craftvia-module": o.module, + "x-craftvia-permissions": o.permissions, + ...(o.parameters ? { parameters: o.parameters } : {}), + ...(o.requestBody ? { requestBody: o.requestBody } : {}), + responses: o.responses, + ...(o.security ? { security: o.security } : {}), +}); + +// ---------- enums (mirrors of client-safe constants) ---------- + +const WORK_ORDER_STATUSES = [ + "draft", + "review_required", + "planned", + "assigned", + "accepted", + "en_route", + "in_progress", + "paused", + "waiting_material", + "daily_report_created", + "technically_completed", + "signature_pending", + "in_review", + "released_for_billing", + "billed", + "cancelled", +]; +const STATUS_GROUPS = ["new", "planned", "en_route", "in_progress", "documentation_incomplete", "in_review", "ready_for_billing", "billed", "cancelled"]; +const PRESETS = ["open", "today", "running", "not_accepted", "overdue", "reports_in_review", "completed", "billing", "emergency_new", "missing_signatures"]; +const SORT_FIELDS = ["plannedStart", "createdAt", "updatedAt", "number", "priority", "status"]; +const PRIORITIES = ["low", "normal", "high", "urgent"]; +const BILLING_TYPES = ["fixed", "time_material", "maintenance_contract", "warranty"]; +const SYNC_OP_TYPES = [ + "session.start", + "session.pause", + "session.resume", + "session.end", + "work_order.transition", + "note.create", + "checklist.toggle", + "material.upsert", + "photo.attach", + "voice.attach", + "report.save_draft", + "report.submit", + "signature.capture", + "emergency.create", +]; +const NOTE_KINDS = ["work_done", "deviation", "problem", "additional_work", "not_executable", "follow_up", "recommendation", "customer_note", "general"]; +const UPLOAD_CATEGORIES = [ + "order_confirmation", + "technical_drawing", + "floor_plan", + "wiring_diagram", + "assembly_instructions", + "safety_document", + "product_document", + "customer_note", + "other", +]; +const DOCUMENT_VISIBILITIES = ["backoffice_only", "team_lead", "team", "customer_report"]; +const IMPORT_STATUSES = ["uploaded", "processing", "review_required", "confirmed", "failed", "discarded"]; + +// ---------- component schemas ---------- + +const customerFields: Record = { + customerNumber: nstr({ maxLength: 40 }), + companyName: nstr({ maxLength: 200 }), + salutation: nstr({ maxLength: 40 }), + firstName: nstr({ maxLength: 100 }), + lastName: nstr({ maxLength: 100 }), + street: nstr({ maxLength: 200 }), + houseNumber: nstr({ maxLength: 20 }), + postalCode: nstr({ maxLength: 12 }), + city: nstr({ maxLength: 100 }), + country: str({ pattern: "^[A-Z]{2}$", description: "ISO 3166-1 alpha-2; Kleinbuchstaben werden normalisiert." }), + phone: nstr({ maxLength: 50 }), + mobile: nstr({ maxLength: 50 }), + email: nstr({ format: "email", maxLength: 200 }), + notes: nstr({ maxLength: 5000 }), + billingNotes: nstr({ maxLength: 5000 }), + status: str({ enum: ["active", "inactive", "provisional"] }), +}; + +const siteFields: Record = { + customerId: str({ minLength: 1 }), + name: str({ minLength: 1, maxLength: 200 }), + street: nstr({ maxLength: 200 }), + houseNumber: nstr({ maxLength: 20 }), + postalCode: nstr({ maxLength: 12 }), + city: nstr({ maxLength: 100 }), + country: str({ pattern: "^[A-Z]{2}$", description: "Default DE" }), + contactId: nstr({ maxLength: 64, description: "Muss zum Kunden gehören." }), + onSiteContact: nstr({ maxLength: 200 }), + phone: nstr({ maxLength: 50 }), + accessNotes: nstr({ maxLength: 5000 }), + parkingNotes: nstr({ maxLength: 5000 }), + safetyNotes: nstr({ maxLength: 5000 }), + technicalNotes: nstr({ maxLength: 5000 }), + status: str({ enum: ["active", "inactive", "provisional"] }), + latitude: { type: ["number", "null"], minimum: -90, maximum: 90 }, + longitude: { type: ["number", "null"], minimum: -180, maximum: 180 }, +}; + +const addressRef = open({ id: str(), name: str(), street: nstr(), houseNumber: nstr(), postalCode: nstr(), city: nstr() }); +const personRef = open({ id: str(), companyName: nstr(), firstName: nstr(), lastName: nstr() }); +const idName = obj({ id: str(), name: str() }, ["id", "name"]); + +const materialPlanInput = obj( + { + name: str({ minLength: 1, maxLength: 200 }), + articleNumber: nstr({ maxLength: 80 }), + plannedQuantity: num({ exclusiveMinimum: 0, maximum: 1_000_000 }), + unit: str({ minLength: 1, maxLength: 20 }), + notes: nstr({ maxLength: 1000 }), + sortOrder: int({ minimum: 0, maximum: 10_000 }), + }, + ["name", "plannedQuantity", "unit"], +); + +const workOrderMasterFields: Record = { + title: str({ minLength: 1, maxLength: 200 }), + customerId: str({ maxLength: 64 }), + siteId: nstr({ maxLength: 64 }), + contactId: nstr({ maxLength: 64 }), + orderTypeId: nstr({ maxLength: 64 }), + priority: ref("WorkOrderPriority"), + description: nstr({ maxLength: 10_000 }), + scope: nstr({ maxLength: 10_000 }), + plannedStart: nDateTime(), + plannedEnd: nDateTime({ description: "Darf nicht vor plannedStart liegen." }), + signatureRequired: bool({ description: "Fehlt → Vorgabe des Auftragstyps (Default true)." }), + billingType: { type: ["string", "null"], enum: [...BILLING_TYPES, null] }, + internalNotes: nstr({ maxLength: 5000 }), + technicianNotes: nstr({ maxLength: 5000 }), + externalOrderNumber: nstr({ maxLength: 80 }), + offerNumber: nstr({ maxLength: 80 }), + emergencyReason: nstr({ maxLength: 2000 }), +}; + +const isoDateTimeOffset = dateTime({ description: "ISO 8601 mit Zeitzonen-Offset" }); +const opId = str({ minLength: 1, maxLength: 64 }); +const uuid = str({ format: "uuid" }); + +const schemas: Record = { + Error: obj( + { + error: obj( + { + code: str({ enum: ["unauthorized", "forbidden", "not_found", "conflict", "invalid", "blocked", "payload_too_large", "rate_limited", "internal"] }), + message: str(), + details: { description: "Optional; je Code: invalid → ValidationIssue[], blocked → z. B. CompletionBlocker[], conflict → z. B. { reason, candidates }, rate_limited → { retryAfterSeconds }." }, + }, + ["code", "message"], + ), + }, + ["error"], + ), + ValidationIssue: obj({ path: str({ description: "Punkt-getrennter Feldpfad" }), code: str({ description: "Zod-Issue-Code" }) }, ["path", "code"]), + Pagination: obj({ page: int({ minimum: 1 }), pageSize: int({ minimum: 1, maximum: 100 }), total: int({ minimum: 0 }) }, ["page", "pageSize", "total"]), + CompletionBlocker: { + oneOf: [ + obj({ kind: str({ const: "checklist_item" }), itemId: str(), label: str() }, ["kind", "itemId", "label"]), + obj({ kind: str({ const: "photo_requirement" }), requirementId: str(), label: str() }, ["kind", "requirementId", "label"]), + obj({ kind: str({ const: "running_session" }), sessionId: str(), userId: str() }, ["kind", "sessionId", "userId"]), + obj({ kind: str({ const: "missing_field" }), field: str() }, ["kind", "field"]), + ], + }, + WorkOrderStatus: str({ enum: WORK_ORDER_STATUSES }), + WorkOrderPriority: str({ enum: PRIORITIES }), + StatusGroup: str({ enum: STATUS_GROUPS }), + + // --- customers --- + CustomerCreate: obj({ ...customerFields, acknowledgeDuplicates: bool({ description: "true übergeht die Dubletten-Prüfung (sonst 409 possible_duplicates)." }) }, [], { + description: "companyName oder lastName ist Pflicht. Leere Strings werden zu null.", + }), + CustomerPatch: obj(customerFields, [], { description: "Fehlende Felder bleiben unverändert, null leert das Feld." }), + CustomerListItem: open( + { + id: str(), + customerNumber: nstr(), + companyName: nstr(), + salutation: nstr(), + firstName: nstr(), + lastName: nstr(), + postalCode: nstr(), + city: nstr(), + phone: nstr(), + email: nstr(), + status: str({ enum: ["active", "inactive", "provisional", "merged"] }), + updatedAt: dateTime(), + _count: obj({ sites: int() }), + }, + ["id", "status"], + ), + Contact: open({ id: str(), name: str(), role: nstr(), phone: nstr(), mobile: nstr(), email: nstr(), preferredChannel: { type: ["string", "null"], enum: ["phone", "mobile", "email", null] }, notes: nstr() }, ["id", "name"]), + Customer: open({ id: str(), ...customerFields, status: str({ enum: ["active", "inactive", "provisional", "merged"] }), createdAt: dateTime(), updatedAt: dateTime() }, ["id", "status"]), + CustomerWithContacts: { allOf: [ref("Customer"), obj({ contacts: arr(ref("Contact")) })] }, + + // --- sites --- + SiteCreate: obj(siteFields, ["customerId", "name"]), + Site: open({ id: str(), ...siteFields, createdAt: dateTime(), updatedAt: dateTime() }, ["id", "customerId", "name"]), + SiteListItem: open( + { + id: str(), + name: str(), + street: nstr(), + houseNumber: nstr(), + postalCode: nstr(), + city: nstr(), + status: str(), + customer: open({ id: str(), customerNumber: nstr(), companyName: nstr(), firstName: nstr(), lastName: nstr() }), + _count: obj({ workOrders: int() }), + }, + ["id", "name"], + ), + SiteHistoryEntry: obj( + { + workOrderId: str(), + number: str(), + title: str(), + date: dateTime({ description: "Erster Arbeitsbeginn, sonst plannedStart, sonst createdAt" }), + status: ref("WorkOrderStatus"), + isEmergency: bool(), + orderType: nstr(), + team: nstr(), + workDone: arr(str()), + summary: str({ maxLength: 280 }), + materials: arr(obj({ name: str(), unit: str(), quantity: num() }, ["name", "unit", "quantity"])), + photoCount: int(), + approvedReports: arr(obj({ id: str(), type: str({ enum: ["daily", "completion"] }), reportDate: dateTime(), version: int() }, ["id", "type", "reportDate", "version"])), + signed: bool(), + followUps: arr(str()), + hasOpenFollowUp: bool(), + }, + ["workOrderId", "number", "title", "date", "status"], + ), + + // --- work orders --- + WorkOrderListItem: open( + { + id: str(), + number: str(), + title: str(), + status: ref("WorkOrderStatus"), + priority: ref("WorkOrderPriority"), + plannedStart: nDateTime(), + plannedEnd: nDateTime(), + isEmergency: bool(), + version: int(), + updatedAt: dateTime(), + customer: personRef, + site: { oneOf: [addressRef, { type: "null" }] }, + team: { oneOf: [idName, { type: "null" }] }, + orderType: { oneOf: [idName, { type: "null" }] }, + assignees: arr(obj({ user: idName })), + }, + ["id", "number", "title", "status", "version"], + ), + WorkOrderList: obj( + { + items: arr(ref("WorkOrderListItem")), + total: int(), + page: int(), + pageSize: int(), + groupCounts: { type: "object", description: "Anzahl je Statusgruppe (Filter ohne status/group)", propertyNames: ref("StatusGroup"), additionalProperties: int() }, + }, + ["items", "total", "page", "pageSize", "groupCounts"], + ), + WorkOrderCreate: obj( + { + ...workOrderMasterFields, + status: str({ enum: ["draft", "review_required", "planned", "in_progress"], default: "draft" }), + isEmergency: bool({ default: false }), + applyTemplate: bool({ default: true, description: "Checkliste/Fotovorgaben aus der Vorlage des Auftragstyps übernehmen" }), + numberKey: str({ enum: ["work_order", "emergency"], default: "work_order" }), + materials: arr(ref("MaterialPlanInput"), { maxItems: 200 }), + checklistItems: arr( + obj({ key: str({ pattern: "^[a-z0-9_]+$", maxLength: 60 }), label: str({ minLength: 1, maxLength: 200 }), required: bool(), requiresPhoto: bool(), sortOrder: int() }, ["label"]), + { maxItems: 200 }, + ), + photoRequirements: arr(obj({ key: str({ pattern: "^[a-z0-9_]+$", maxLength: 60 }), label: str({ minLength: 1, maxLength: 200 }), sortOrder: int() }, ["label"]), { maxItems: 50 }), + }, + ["title", "customerId"], + { description: "`sourceImportId` wird von der API verworfen (nur Import-Service)." }, + ), + WorkOrderPatch: obj({ ...workOrderMasterFields, baseVersion: int({ minimum: 1, description: "Optimistische Sperre: ≠ aktuelle Version → 409" }) }, [], { + additionalProperties: false, + description: "Teilaktualisierung der Stammdaten (strict: unbekannte Felder → 422).", + }), + WorkOrder: open( + { + id: str(), + number: str(), + title: str(), + status: ref("WorkOrderStatus"), + priority: ref("WorkOrderPriority"), + version: int(), + isEmergency: bool(), + plannedStart: nDateTime(), + plannedEnd: nDateTime(), + customerId: str(), + siteId: nstr(), + contactId: nstr(), + orderTypeId: nstr(), + description: nstr(), + scope: nstr(), + createdAt: dateTime(), + updatedAt: dateTime(), + }, + ["id", "number", "title", "status", "version"], + ), + WorkOrderDetail: { + allOf: [ + ref("WorkOrder"), + obj({ + customer: open({ id: str(), customerNumber: nstr(), companyName: nstr(), firstName: nstr(), lastName: nstr(), phone: nstr(), email: nstr(), street: nstr(), houseNumber: nstr(), postalCode: nstr(), city: nstr() }), + site: { oneOf: [open({ id: str(), name: str(), accessNotes: nstr(), safetyNotes: nstr() }), { type: "null" }] }, + contact: { oneOf: [open({ id: str(), name: str(), phone: nstr(), mobile: nstr(), email: nstr() }), { type: "null" }] }, + orderType: { oneOf: [open({ id: str(), name: str(), key: str() }), { type: "null" }] }, + team: { oneOf: [idName, { type: "null" }] }, + teamLead: { oneOf: [idName, { type: "null" }] }, + assignees: arr(obj({ user: idName })), + }), + ], + }, + WorkOrderDetailResponse: obj( + { workOrder: ref("WorkOrderDetail"), availableTransitions: arr(ref("WorkOrderStatus"), { description: "Übergänge, die der Aufrufer jetzt auslösen darf" }), completionBlockers: arr(ref("CompletionBlocker")) }, + ["workOrder", "availableTransitions", "completionBlockers"], + ), + VersionResult: obj({ id: str(), version: int() }, ["id", "version"]), + TransitionRequest: obj({ to: ref("WorkOrderStatus"), reason: nstr({ maxLength: 2000 }), baseVersion: int({ minimum: 1 }) }, ["to"]), + TransitionResult: obj({ id: str(), status: ref("WorkOrderStatus"), from: ref("WorkOrderStatus"), version: int() }, ["id", "status", "from", "version"]), + AssignRequest: obj({ teamId: str({ maxLength: 64 }), userIds: arr(str({ maxLength: 64 }), { maxItems: 50, default: [] }), teamLeadUserId: nstr({ maxLength: 64 }), baseVersion: int({ minimum: 1 }) }, ["teamId"]), + AssignResult: obj({ id: str(), version: int(), status: ref("WorkOrderStatus") }, ["id", "version", "status"]), + MaterialPlanInput: materialPlanInput, + MaterialPlan: open({ id: str(), workOrderId: str(), name: str(), articleNumber: nstr(), plannedQuantity: num(), unit: str(), notes: nstr(), sortOrder: int() }, ["id", "workOrderId", "name", "plannedQuantity", "unit"]), + MaterialRow: obj( + { + planId: nstr({ description: "null = Mehrmaterial ohne Vorgabe" }), + name: str(), + articleNumber: nstr(), + unit: str(), + planned: { type: ["number", "null"] }, + actual: { type: ["number", "null"] }, + deviation: { type: ["number", "null"] }, + statuses: arr(str({ enum: ["fully_used", "partially_used", "not_used", "additional"] })), + reasons: arr(str()), + notes: nstr(), + }, + ["planId", "name", "unit", "planned", "actual", "deviation", "statuses", "reasons", "notes"], + ), + WorkOrderDocument: obj( + { id: str(), fileName: str(), storageKey: str(), category: str({ enum: UPLOAD_CATEGORIES }), visibility: str({ enum: DOCUMENT_VISIBILITIES }) }, + ["id", "fileName", "storageKey", "category", "visibility"], + ), + + // --- imports --- + ImportCreated: obj({ id: str(), status: str({ enum: IMPORT_STATUSES }) }, ["id", "status"]), + ImportDetail: open( + { + id: str(), + status: str({ enum: IMPORT_STATUSES }), + errorMessage: nstr(), + createdAt: dateTime(), + confirmedAt: nDateTime(), + provider: nstr(), + extractionModel: nstr(), + extractionVersion: { type: ["string", "integer", "null"] }, + extractedText: nstr(), + importedByName: nstr(), + document: { oneOf: [open({ id: str(), fileName: str(), mimeType: str(), fileSize: int(), visibility: str(), createdAt: dateTime() }), { type: "null" }] }, + extraction: open({ siteCandidates: arr(open({ customerId: str() })) }), + corrections: { description: "Diff Extraktion ↔ bestätigte Werte: { \"\": { from, to } } oder null" }, + createdWorkOrder: { oneOf: [obj({ id: str(), number: str(), status: ref("WorkOrderStatus") }), { type: "null" }] }, + customerCandidates: arr(open({ customerId: str(), customer: open({ id: str() }) })), + siteCandidates: arr(open({ customerId: str() })), + }, + ["id", "status"], + ), + ImportReviewForm: obj( + { + customerMode: str({ enum: ["existing", "new"] }), + customerId: str({ description: "Pflicht bei customerMode=existing" }), + customer: obj({ + customerNumber: str({ maxLength: 50 }), + companyName: str({ maxLength: 200 }), + firstName: str({ maxLength: 100 }), + lastName: str({ maxLength: 100 }), + street: str({ maxLength: 200 }), + houseNumber: str({ maxLength: 20 }), + postalCode: str({ maxLength: 10 }), + city: str({ maxLength: 100 }), + country: str({ maxLength: 2, default: "DE" }), + phone: str({ maxLength: 50 }), + email: str({ maxLength: 200 }), + }), + siteMode: str({ enum: ["existing", "new", "none"] }), + siteId: str({ description: "Pflicht bei siteMode=existing" }), + site: obj({ name: str({ maxLength: 200 }), street: str({ maxLength: 200 }), houseNumber: str({ maxLength: 20 }), postalCode: str({ maxLength: 10 }), city: str({ maxLength: 100 }), country: str({ maxLength: 2 }) }), + contact: obj({ name: str({ maxLength: 200 }), phone: str({ maxLength: 50 }), email: str({ maxLength: 200 }) }), + order: obj( + { + title: str({ minLength: 1, maxLength: 200 }), + externalOrderNumber: str({ maxLength: 100 }), + offerNumber: str({ maxLength: 100 }), + description: str({ maxLength: 10_000 }), + plannedStart: str({ description: "Datum (ISO oder deutsches Format)" }), + plannedEnd: str(), + notes: str({ maxLength: 10_000 }), + }, + ["title"], + ), + positions: arr(obj({ name: str({ minLength: 1, maxLength: 300 }), articleNumber: str({ maxLength: 100 }), quantity: { type: ["number", "string"] }, unit: str({ maxLength: 30 }), asMaterial: bool() }, ["name"]), { maxItems: 500 }), + }, + ["customerMode", "customer", "siteMode", "site", "contact", "order"], + { description: "Prüfformular (src/lib/imports/review.ts#reviewFormSchema)." }, + ), + ImportConfirmResult: obj({ workOrderId: str(), workOrderNumber: str(), customerId: str(), siteId: nstr(), contactId: nstr() }, ["workOrderId", "workOrderNumber", "customerId", "siteId", "contactId"]), + + // --- reports --- + Report: obj( + { id: str(), type: str({ enum: ["daily", "completion"] }), status: str({ description: "z. B. draft, submitted, team_approved, approved, rejected, superseded" }), version: int(), workOrderId: str(), lineageId: str(), hasPdf: bool() }, + ["id", "type", "status", "version", "workOrderId", "lineageId", "hasPdf"], + ), + ReportCreateRequest: obj({ reportDate: str({ pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "Nur Tagesbericht; Default heute" }), clientId: str({ maxLength: 64, description: "Idempotenzschlüssel des Geräts" }) }), + ReportCreateResponse: obj({ report: ref("Report"), created: bool() }, ["report", "created"]), + ReportResponse: obj({ report: ref("Report") }, ["report"]), + + // --- sync --- + SyncRequest: obj({ deviceId: str({ maxLength: 64 }), operations: arr(ref("SyncOperation"), { minItems: 1, maxItems: 100 }) }, ["deviceId", "operations"]), + SyncOperation: obj( + { + clientOpId: str({ format: "uuid", description: "Idempotenzschlüssel je Operation (je Mandant)" }), + opType: str({ enum: SYNC_OP_TYPES }), + entityType: str({ maxLength: 40 }), + entityId: str({ maxLength: 64 }), + baseVersion: int({ minimum: 1, description: "Pflicht für work_order.transition und report.submit (WorkOrder.version)" }), + payload: { + type: "object", + additionalProperties: true, + description: + "Payload je opType: session.start → SyncPayloadSessionStart; session.pause/resume/end → SyncPayloadSessionControl; work_order.transition → SyncPayloadTransition; note.create → SyncPayloadNoteCreate; checklist.toggle → SyncPayloadChecklistToggle; material.upsert → SyncPayloadMaterialUpsert; photo.attach → SyncPayloadPhotoAttach; voice.attach → SyncPayloadVoiceAttach; emergency.create → SyncPayloadEmergencyCreate; report.save_draft → { workOrderId, reportId, texts: Partial }; report.submit → { workOrderId, reportId, aiReviewed?: boolean } (aiReviewed Pflicht für Lotse-Entwürfe, sonst rejected invalid; baseVersion Pflicht); signature.capture → noch nicht offline verfügbar (rejected invalid, nicht gespeichert).", + }, + clientCreatedAt: dateTime({ description: "ISO 8601 (UTC, `Z`)" }), + }, + ["clientOpId", "opType", "payload", "clientCreatedAt"], + ), + SyncPayloadSessionStart: obj( + { + workOrderId: opId, + clientId: uuid, + mode: str({ enum: ["travel", "work"], default: "work" }), + at: isoDateTimeOffset, + latitude: num({ minimum: -90, maximum: 90 }), + longitude: num({ minimum: -180, maximum: 180 }), + offline: bool({ default: false }), + deviceInfo: str({ maxLength: 200 }), + }, + ["workOrderId"], + ), + SyncPayloadSessionControl: obj({ workOrderId: opId, at: isoDateTimeOffset }, ["workOrderId"]), + SyncPayloadTransition: obj({ workOrderId: opId, to: ref("WorkOrderStatus"), reason: str({ maxLength: 1000 }) }, ["workOrderId", "to"]), + SyncPayloadNoteCreate: obj({ workOrderId: opId, clientId: uuid, kind: str({ enum: NOTE_KINDS, default: "general" }), text: str({ minLength: 1, maxLength: 10_000 }) }, ["workOrderId", "text"]), + SyncPayloadChecklistToggle: obj({ workOrderId: opId, itemId: opId, checked: bool(), comment: nstr({ maxLength: 2000 }) }, ["workOrderId", "itemId", "checked"]), + SyncPayloadMaterialUpsert: obj( + { + workOrderId: opId, + clientId: uuid, + materialPlanId: nstr({ maxLength: 64 }), + name: str({ maxLength: 200 }), + articleNumber: nstr({ maxLength: 100 }), + quantity: num({ minimum: 0, maximum: 1_000_000 }), + unit: str({ minLength: 1, maxLength: 20 }), + usageStatus: str({ enum: ["fully_used", "partially_used", "not_used", "additional"] }), + deviationReason: nstr({ maxLength: 2000 }), + notes: nstr({ maxLength: 2000 }), + photoId: nstr({ maxLength: 64 }), + }, + ["workOrderId", "quantity", "unit", "usageStatus"], + ), + SyncPayloadPhotoAttach: obj( + { + workOrderId: opId, + clientId: uuid, + documentId: str({ maxLength: 64, description: "Aus POST /uploads" }), + phase: { type: ["string", "null"], enum: ["before", "during", "after", null] }, + photoRequirementId: nstr({ maxLength: 64 }), + checklistItemId: nstr({ maxLength: 64 }), + comment: nstr({ maxLength: 2000 }), + takenAt: isoDateTimeOffset, + latitude: num({ minimum: -90, maximum: 90 }), + longitude: num({ minimum: -180, maximum: 180 }), + }, + ["workOrderId", "documentId"], + ), + SyncPayloadVoiceAttach: obj( + { workOrderId: opId, clientId: uuid, documentId: str({ maxLength: 64 }), durationSeconds: int({ minimum: 0, maximum: 300 }), recordedAt: isoDateTimeOffset, kind: str({ enum: NOTE_KINDS }) }, + ["workOrderId", "documentId"], + ), + SyncPayloadEmergencyCreate: obj( + { + clientIds: obj({ workOrder: uuid, session: uuid, customer: uuid, site: uuid }, ["workOrder", "session"]), + customer: { + oneOf: [ + obj({ mode: str({ const: "existing" }), customerId: opId }, ["mode", "customerId"]), + obj( + { mode: str({ const: "new" }), companyName: nstr({ maxLength: 200 }), firstName: nstr({ maxLength: 100 }), lastName: nstr({ maxLength: 100 }), phone: str({ minLength: 1, maxLength: 50 }), email: nstr({ format: "email" }), street: nstr(), houseNumber: nstr(), postalCode: nstr(), city: nstr() }, + ["mode", "phone"], + ), + ], + }, + site: { + oneOf: [ + obj({ mode: str({ const: "existing" }), siteId: opId }, ["mode", "siteId"]), + obj({ mode: str({ const: "new" }), name: nstr({ maxLength: 200 }), street: str({ minLength: 1, maxLength: 200 }), houseNumber: nstr(), postalCode: nstr(), city: str({ minLength: 1, maxLength: 100 }) }, ["mode", "street", "city"]), + ], + }, + onSiteContact: obj({ name: str({ minLength: 1, maxLength: 200 }), phone: str({ minLength: 1, maxLength: 50 }) }, ["name", "phone"]), + reason: str({ minLength: 1, maxLength: 2000 }), + startedAt: isoDateTimeOffset, + teamId: nstr({ maxLength: 64 }), + assigneeIds: arr(opId, { maxItems: 20 }), + offline: bool({ default: false }), + deviceInfo: str({ maxLength: 200 }), + }, + ["clientIds", "customer", "site", "onSiteContact", "reason"], + ), + SyncOpResult: obj( + { + clientOpId: str({ format: "uuid" }), + status: str({ enum: ["applied", "duplicate", "conflict", "rejected"] }), + idMap: { type: "object", additionalProperties: str(), description: "Client-ID → Server-ID der erzeugten Objekte" }, + entityVersion: int({ description: "Neue bzw. (bei conflict) aktuelle WorkOrder.version" }), + errorCode: str({ enum: ["not_found", "forbidden", "invalid", "conflict", "blocked", "internal"] }), + message: str({ description: "Bei blocked: JSON-kodierte CompletionBlocker[]" }), + }, + ["clientOpId", "status"], + ), + SyncResponse: obj({ results: arr(ref("SyncOpResult")), serverTime: dateTime() }, ["results", "serverTime"]), + + // --- field --- + UploadResult: obj({ documentId: str(), duplicate: bool() }, ["documentId", "duplicate"]), + FieldBundle: obj( + { + serverTime: dateTime({ description: "Als nächstes `since` verwenden" }), + since: nDateTime(), + orders: arr( + open( + { + id: str(), + number: str(), + title: str(), + status: ref("WorkOrderStatus"), + statusGroup: ref("StatusGroup"), + priority: ref("WorkOrderPriority"), + isEmergency: bool(), + plannedStart: nDateTime(), + plannedEnd: nDateTime(), + version: int(), + updatedAt: dateTime(), + externalOrderNumber: nstr(), + description: nstr(), + scope: nstr(), + technicianNotes: nstr(), + signatureRequired: bool(), + customer: { type: ["object", "null"], additionalProperties: true }, + contact: { type: ["object", "null"], additionalProperties: true }, + site: { type: ["object", "null"], additionalProperties: true }, + orderType: { type: ["object", "null"], additionalProperties: true }, + checklistItems: arr({ type: "object", additionalProperties: true }), + photoRequirements: arr({ type: "object", additionalProperties: true }), + materialPlans: arr({ type: "object", additionalProperties: true }), + materialUsages: arr({ type: "object", additionalProperties: true }), + documents: arr(open({ id: str(), title: nstr(), fileName: str(), category: str(), mimeType: str(), fileSize: int(), checksum: str(), version: int(), lineageId: nstr() })), + siteHistory: arr({ type: "object", additionalProperties: true, description: "Letzte 5 freigegebene Einsätze am Standort" }), + }, + ["id", "number", "status", "version"], + ), + { maxItems: 200 }, + ), + }, + ["serverTime", "since", "orders"], + ), +}; + +const errorContent = { "application/json": { schema: ref("Error") } }; +const responses: Record = { + Unauthorized: { description: "`unauthorized` – kein/abgelaufenes Session-Cookie (ohne Cookie antwortet bereits der Proxy), Konto inaktiv, Sitzung invalidiert.", content: errorContent }, + Forbidden: { description: "`forbidden` – Recht fehlt (DB-autoritativ), Modul deaktiviert, Passwortwechsel erforderlich oder Cross-Site-Request (CSRF).", content: errorContent }, + NotFound: { description: "`not_found` – unbekannt, fremder Mandant oder außerhalb des Sichtbarkeits-Scopes.", content: errorContent }, + Conflict: { description: "`conflict` – Versionskonflikt (baseVersion), Doppelbestätigung/unzulässiger Zustand oder mögliche Dubletten (details.reason = \"possible_duplicates\").", content: errorContent }, + Unprocessable: { description: "`invalid` (Validierung; details = ValidationIssue[]; auch fehlerhaftes JSON/Multipart) oder `blocked` (fachlich gesperrt, z. B. details = CompletionBlocker[]).", content: errorContent }, + PayloadTooLarge: { description: "`payload_too_large` – Datei/Body zu groß.", content: errorContent }, + RateLimited: { + description: "`rate_limited` – Limit je Nutzer pro Minute überschritten; details.retryAfterSeconds.", + headers: { "Retry-After": { schema: int({ minimum: 1 }), description: "Sekunden" } }, + content: errorContent, + }, + Internal: { description: "`internal` – unerwarteter Fehler (keine internen Details).", content: errorContent }, +}; + +const parameters: Record = { + Page: query("page", int({ minimum: 1, default: 1 })), + PageSize: query("pageSize", int({ minimum: 1, maximum: 100, default: 25 })), + PageSizeHistory: query("pageSize", int({ minimum: 1, maximum: 100, default: 50 })), + Download: query("download", str({ enum: ["1"] }), "`1` → Content-Disposition attachment"), +}; +const p = (name: string): Schema => ({ $ref: `#/components/parameters/${name}` }); + +const idParam = (what: string) => pathParam("id", `ID ${what}`); +const listOf = (item: string): Schema => obj({ data: arr(ref(item)), pagination: ref("Pagination") }, ["data", "pagination"]); +const dataOf = (item: string): Schema => obj({ data: ref(item) }, ["data"]); + +// ---------- paths (relative to servers[0].url = /api/v1) ---------- + +const paths: Record> = { + "/customers": { + get: op({ + tag: "Stammdaten", + operationId: "listCustomers", + summary: "Kunden suchen/auflisten", + module: "customers", + permissions: ["customer:read"], + parameters: [query("q", str(), "Suche in Nummer, Firma, Name, Ort, E-Mail"), query("status", str({ enum: ["active", "inactive", "provisional", "merged", "all"] }), "Default: alle außer merged"), p("Page"), p("PageSize")], + responses: { "200": jsonResponse("Seite", listOf("CustomerListItem")), ...errors("unprocessable") }, + }), + post: op({ + tag: "Stammdaten", + operationId: "createCustomer", + summary: "Kunden anlegen", + description: "Mögliche Dubletten ohne `acknowledgeDuplicates: true` → 409 mit details `{ reason: \"possible_duplicates\", candidates }`.", + module: "customers", + permissions: ["customer:write"], + requestBody: jsonBody(ref("CustomerCreate")), + responses: { "201": jsonResponse("Angelegt", dataOf("Customer")), ...errors("conflict", "unprocessable") }, + }), + }, + "/customers/{id}": { + get: op({ + tag: "Stammdaten", + operationId: "getCustomer", + summary: "Kunde inkl. Ansprechpartner", + module: "customers", + permissions: ["customer:read"], + parameters: [idParam("des Kunden")], + responses: { "200": jsonResponse("Kunde", dataOf("CustomerWithContacts")), ...errors("not_found") }, + }), + patch: op({ + tag: "Stammdaten", + operationId: "updateCustomer", + summary: "Kunden ändern (absent = unverändert, null = leeren)", + module: "customers", + permissions: ["customer:write"], + parameters: [idParam("des Kunden")], + requestBody: jsonBody(ref("CustomerPatch")), + responses: { "200": jsonResponse("Geändert", dataOf("Customer")), ...errors("not_found", "conflict", "unprocessable") }, + }), + }, + "/sites": { + get: op({ + tag: "Stammdaten", + operationId: "listSites", + summary: "Standorte suchen/auflisten", + module: "sites", + permissions: ["site:read"], + parameters: [query("q", str()), query("customerId", str()), query("status", str({ enum: ["active", "inactive", "provisional", "all"] })), p("Page"), p("PageSize")], + responses: { "200": jsonResponse("Seite", listOf("SiteListItem")), ...errors("unprocessable") }, + }), + post: op({ + tag: "Stammdaten", + operationId: "createSite", + summary: "Standort anlegen", + module: "sites", + permissions: ["site:write"], + requestBody: jsonBody(ref("SiteCreate")), + responses: { "201": jsonResponse("Angelegt", dataOf("Site")), ...errors("unprocessable") }, + }), + }, + "/sites/{id}/history": { + get: op({ + tag: "Stammdaten", + operationId: "getSiteHistory", + summary: "Einsatzhistorie eines Standorts (neueste zuerst)", + description: "Rollen ohne `work_order:read_all` erhalten immer nur freigegebene Einsätze (unabhängig von onlyApproved). Interne Notizen sind nie enthalten.", + module: "sites", + permissions: ["site:read"], + parameters: [idParam("des Standorts"), query("onlyApproved", str({ enum: ["true", "1", "false"] })), p("Page"), p("PageSizeHistory")], + responses: { + "200": jsonResponse( + "Seite", + obj({ data: arr(ref("SiteHistoryEntry")), pagination: ref("Pagination"), meta: obj({ onlyApproved: bool() }, ["onlyApproved"]) }, ["data", "pagination", "meta"]), + ), + ...errors("not_found"), + }, + }), + }, + "/work-orders": { + get: op({ + tag: "Aufträge", + operationId: "listWorkOrders", + summary: "Aufträge filtern (immer im Sichtbarkeits-Scope)", + description: "Scope: `work_order:read_all` → alle, `work_order:read_team` → eigene/Team, sonst leer. Ungültige Filterwerte werden ignoriert.", + module: "work_orders", + permissions: [], + parameters: [ + query("q", str({ maxLength: 100 })), + query("status", str(), "Komma-getrennte WorkOrderStatus-Werte"), + query("group", ref("StatusGroup")), + query("preset", str({ enum: PRESETS })), + query("from", str({ format: "date" }), "YYYY-MM-DD"), + query("to", str({ format: "date" }), "YYYY-MM-DD (inkl.)"), + query("customerId", str()), + query("siteId", str()), + query("teamId", str()), + query("userId", str()), + query("orderTypeId", str()), + query("priority", ref("WorkOrderPriority")), + query("sort", str({ enum: SORT_FIELDS, default: "plannedStart" })), + query("dir", str({ enum: ["asc", "desc"], default: "asc" })), + query("page", int({ minimum: 1, maximum: 10_000, default: 1 })), + query("pageSize", int({ minimum: 1, maximum: 100, default: 25 })), + ], + responses: { "200": jsonResponse("Liste", ref("WorkOrderList")), ...errors() }, + }), + post: op({ + tag: "Aufträge", + operationId: "createWorkOrder", + summary: "Auftrag anlegen", + description: "Recht im Service: `work_order:write` (oder `emergency:create` bei isEmergency).", + module: "work_orders", + permissions: ["work_order:write"], + requestBody: jsonBody(ref("WorkOrderCreate")), + responses: { "201": jsonResponse("Angelegt", ref("WorkOrder")), ...errors("not_found", "unprocessable") }, + }), + }, + "/work-orders/{id}": { + get: op({ + tag: "Aufträge", + operationId: "getWorkOrder", + summary: "Auftragsdetail inkl. möglicher Übergänge und Abschluss-Blocker", + module: "work_orders", + permissions: [], + parameters: [idParam("des Auftrags")], + responses: { "200": jsonResponse("Detail", ref("WorkOrderDetailResponse")), ...errors("not_found") }, + }), + patch: op({ + tag: "Aufträge", + operationId: "updateWorkOrder", + summary: "Stammdaten ändern (optimistische Sperre über baseVersion)", + module: "work_orders", + permissions: ["work_order:write"], + parameters: [idParam("des Auftrags")], + requestBody: jsonBody(ref("WorkOrderPatch")), + responses: { "200": jsonResponse("Neue Version", ref("VersionResult")), ...errors("not_found", "conflict", "unprocessable") }, + }), + }, + "/work-orders/{id}/assign": { + post: op({ + tag: "Aufträge", + operationId: "assignWorkOrder", + summary: "Team/Monteure zuweisen", + module: "work_orders", + permissions: ["work_order:assign"], + parameters: [idParam("des Auftrags")], + requestBody: jsonBody(ref("AssignRequest")), + responses: { "200": jsonResponse("Zugewiesen", ref("AssignResult")), ...errors("not_found", "conflict", "unprocessable") }, + }), + }, + "/work-orders/{id}/transition": { + post: op({ + tag: "Aufträge", + operationId: "transitionWorkOrder", + summary: "Statusübergang", + description: "Recht hängt vom Übergang ab (src/lib/work-orders/status.ts#requiredPermission, z. B. field:execute, work_order:cancel, work_order:release_billing). 422 `blocked` mit details = CompletionBlocker[].", + module: "work_orders", + permissions: [], + parameters: [idParam("des Auftrags")], + requestBody: jsonBody(ref("TransitionRequest")), + responses: { "200": jsonResponse("Übergang ausgeführt", ref("TransitionResult")), ...errors("not_found", "conflict", "unprocessable") }, + }), + }, + "/work-orders/{id}/materials": { + get: op({ + tag: "Aufträge", + operationId: "getWorkOrderMaterials", + summary: "Material Soll/Ist inkl. Abweichungen", + module: "work_orders", + permissions: [], + parameters: [idParam("des Auftrags")], + responses: { "200": jsonResponse("Übersicht", obj({ items: arr(ref("MaterialRow")) }, ["items"])), ...errors("not_found") }, + }), + post: op({ + tag: "Aufträge", + operationId: "addWorkOrderMaterialPlan", + summary: "Materialvorgabe hinzufügen", + module: "work_orders", + permissions: ["work_order:write"], + parameters: [idParam("des Auftrags")], + requestBody: jsonBody(ref("MaterialPlanInput")), + responses: { "201": jsonResponse("Angelegt", ref("MaterialPlan")), ...errors("not_found", "unprocessable") }, + }), + }, + "/work-orders/{id}/documents": { + post: op({ + tag: "Aufträge", + operationId: "uploadWorkOrderDocument", + summary: "Dokument zum Auftrag hochladen (multipart)", + description: "Mit `Accept: text/html` (Backoffice-Formular) antwortet die Route mit 303 zurück auf den Dokumente-Tab (Fehler als Query `uploadError`). Sichtbarkeit `backoffice_only` erfordert `document:read_internal`.", + module: "work_orders", + permissions: ["document:write"], + parameters: [idParam("des Auftrags")], + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: obj( + { file: str({ contentMediaType: "application/octet-stream" }), category: str({ enum: UPLOAD_CATEGORIES, default: "other" }), visibility: str({ enum: DOCUMENT_VISIBILITIES, default: "team" }), title: str() }, + ["file"], + ), + }, + }, + }, + responses: { + "201": jsonResponse("Gespeichert", ref("WorkOrderDocument")), + "303": { description: "Redirect (nur bei Accept: text/html)" }, + ...errors("not_found", "unprocessable", "payload_too_large"), + }, + }), + }, + "/work-orders/{id}/daily-report": { + post: op({ + tag: "Berichte", + operationId: "createDailyReport", + summary: "Tagesbericht-Entwurf anlegen oder vorhandenen zurückgeben", + description: "Idempotent je (Auftrag, Tag) bzw. clientId: vorhandener Entwurf/abgelehnter Bericht → 200, neu → 201. Bereits eingereicht → 409.", + module: "reports", + permissions: ["report:write"], + parameters: [idParam("des Auftrags")], + requestBody: jsonBody(ref("ReportCreateRequest"), false), + responses: { "200": jsonResponse("Vorhanden", ref("ReportCreateResponse")), "201": jsonResponse("Angelegt", ref("ReportCreateResponse")), ...errors("not_found", "conflict", "unprocessable") }, + }), + }, + "/work-orders/{id}/completion-report": { + post: op({ + tag: "Berichte", + operationId: "createCompletionReport", + summary: "Abschlussbericht-Entwurf anlegen oder vorhandenen zurückgeben", + description: "Offene Pflichtpunkte → 422 `blocked` mit details = CompletionBlocker[].", + module: "reports", + permissions: ["report:write"], + parameters: [idParam("des Auftrags")], + requestBody: jsonBody(obj({ clientId: str({ maxLength: 64 }) }), false), + responses: { "200": jsonResponse("Vorhanden", ref("ReportCreateResponse")), "201": jsonResponse("Angelegt", ref("ReportCreateResponse")), ...errors("not_found", "conflict", "unprocessable") }, + }), + }, + "/work-orders/import": { + post: op({ + tag: "Import", + operationId: "createImport", + summary: "Auftragsdokument hochladen (multipart), Extraktion läuft im Hintergrund", + description: "Erlaubt: application/pdf, image/jpeg, image/png; max. 25 MB.", + module: "imports", + permissions: ["import:write"], + requestBody: { required: true, content: { "multipart/form-data": { schema: obj({ file: str({ contentMediaType: "application/octet-stream" }) }, ["file"]) } } }, + responses: { "201": jsonResponse("Import angelegt", ref("ImportCreated")), ...errors("unprocessable", "payload_too_large") }, + }), + }, + "/imports/{id}": { + get: op({ + tag: "Import", + operationId: "getImport", + summary: "Importstatus, Extraktion (mit Konfidenzen), Kandidaten", + module: "imports", + permissions: ["import:write"], + parameters: [idParam("des Imports")], + responses: { "200": jsonResponse("Import", ref("ImportDetail")), ...errors("not_found") }, + }), + }, + "/imports/{id}/confirm": { + post: op({ + tag: "Import", + operationId: "confirmImport", + summary: "Geprüften Import bestätigen → Kunde/Standort/Kontakt/Auftrag", + description: "Nur im Status review_required; erneute Bestätigung → 409.", + module: "imports", + permissions: ["import:write", "work_order:write"], + parameters: [idParam("des Imports")], + requestBody: jsonBody(ref("ImportReviewForm")), + responses: { "200": jsonResponse("Bestätigt", ref("ImportConfirmResult")), ...errors("not_found", "conflict", "unprocessable") }, + }), + }, + "/reports/{id}/approve": { + post: op({ + tag: "Berichte", + operationId: "approveReport", + summary: "Bericht freigeben", + description: "Teamleitung (`report:approve_team`) → team_approved; Backoffice (`report:approve`) → approved inkl. PDF-Erzeugung. Falscher Status/parallel geändert → 409.", + module: "reports", + permissions: ["report:read"], + parameters: [idParam("des Berichts")], + responses: { "200": jsonResponse("Freigegeben", ref("ReportResponse")), ...errors("not_found", "conflict") }, + }), + }, + "/reports/{id}/pdf": { + get: op({ + tag: "Berichte", + operationId: "getReportPdf", + summary: "Unveränderliches PDF eines freigegebenen Berichts", + module: "reports", + permissions: ["report:read"], + parameters: [idParam("des Berichts"), p("Download")], + responses: { "200": { ...binaryResponse("PDF"), content: { "application/pdf": { schema: str({ contentMediaType: "application/pdf" }) } } }, ...errors("not_found") }, + }), + }, + "/reports/{id}/files/{documentId}": { + get: op({ + tag: "Berichte", + operationId: "getReportFile", + summary: "Foto/Unterschrift/Logo aus dem Bericht-Snapshot", + module: "reports", + permissions: ["report:read"], + parameters: [idParam("des Berichts"), pathParam("documentId", "Im Snapshot referenzierte Dokument-ID"), p("Download")], + responses: { "200": binaryResponse("Datei"), ...errors("not_found") }, + }), + }, + "/sync": { + post: op({ + tag: "Einsatz", + operationId: "sync", + summary: "Batch von Offline-/Online-Operationen anwenden", + description: + "Jede Operation wird einzeln angewendet; der HTTP-Status ist 200, das Ergebnis steht je Operation in `results`. Idempotenz über `clientOpId` (Wiederholung → `duplicate` mit gespeichertem Ergebnis). `work_order.transition` und `report.submit` verlangen `baseVersion`; Abweichung von WorkOrder.version → `conflict` (entityVersion = aktuelle Version). Rechte je opType im Service (z. B. field:execute, emergency:create).", + module: "field", + permissions: [], + requestBody: jsonBody(ref("SyncRequest")), + responses: { "200": jsonResponse("Ergebnisse je Operation", ref("SyncResponse")), ...errors("unprocessable") }, + }), + }, + "/uploads": { + post: op({ + tag: "Einsatz", + operationId: "uploadFieldFile", + summary: "Foto/Sprachnotiz hochladen (multipart) → documentId", + description: "Idempotent über `clientId`: gleiche clientId → 200 mit derselben documentId und `duplicate: true`, sonst 201. Inhalt wird per Magic Bytes geprüft (photo → Bild, voice_note → Audio). Max. 25 MB; optionales Vorschaubild ≤ 2 MB.", + module: "field", + permissions: ["field:execute"], + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: obj( + { + file: str({ contentMediaType: "application/octet-stream" }), + clientId: str({ format: "uuid" }), + workOrderId: str({ maxLength: 64 }), + kind: str({ enum: ["photo", "voice_note"] }), + preview: str({ contentMediaType: "image/*", description: "Optionales Thumbnail (~400 px)" }), + }, + ["file", "clientId", "workOrderId", "kind"], + ), + }, + }, + }, + responses: { "200": jsonResponse("Bereits vorhanden", ref("UploadResult")), "201": jsonResponse("Gespeichert", ref("UploadResult")), ...errors("not_found", "unprocessable", "payload_too_large") }, + }), + }, + "/field/bundle": { + get: op({ + tag: "Einsatz", + operationId: "getFieldBundle", + summary: "Offline-Pull der Aufträge im Scope (max. 200)", + module: "field", + permissions: ["field:execute"], + parameters: [query("since", dateTime(), "Nur seitdem geänderte Aufträge (serverTime der letzten Antwort)")], + responses: { "200": jsonResponse("Bundle", ref("FieldBundle")), ...errors("unprocessable") }, + }), + }, + "/field/documents/{id}": { + get: op({ + tag: "Einsatz", + operationId: "getFieldDocument", + summary: "Dokument für die Mobile-App (Sichtbarkeit + Scope geprüft)", + description: "Nur magic-byte-verifizierte Typen (JPEG/PNG/WebP, PDF, Audio) inline, sonst Download. `Cache-Control: private, max-age=300`.", + module: "field", + permissions: ["document:read"], + parameters: [idParam("des Dokuments"), query("variant", str({ enum: ["preview"] }), "Vorschaubild statt Original")], + responses: { "200": binaryResponse("Datei"), ...errors("not_found") }, + }), + }, + "/openapi.json": { + get: op({ + tag: "Meta", + operationId: "getOpenApi", + summary: "Dieses OpenAPI-Dokument", + description: "Für jeden angemeldeten Nutzer ohne weitere Rechteprüfung; `Cache-Control: private, max-age=300`.", + module: null, + permissions: [], + responses: { "200": jsonResponse("OpenAPI 3.1", { type: "object" }), "401": { $ref: "#/components/responses/Unauthorized" } }, + }), + }, +}; + +export const API_BASE_PATH = "/api/v1"; + +export const openApiDocument = { + openapi: "3.1.0", + info: { + title: "Craftvia API", + version: "1.0.0", + description: + "Versionierte JSON-API von Craftvia (Backoffice, Mobile/PWA, Integrationen). Authentifizierung per Auth.js-Session-Cookie; schreibende Methoden nur Same-Origin. Einheitliches Fehlerformat `{ error: { code, message, details? } }` mit `Cache-Control: no-store`. Rate Limit je Nutzer/Minute: Standard `API_RATE_LIMIT_PER_MINUTE` (300), `/sync`, `/uploads`, `/field/**` `API_FIELD_RATE_LIMIT_PER_MINUTE` (1200), gezählt je App-Instanz. `x-craftvia-module`/`x-craftvia-permissions` nennen den Modul- und Rechte-Gate der Route; weitere Rechte/Scopes prüfen die Services. Siehe docs/craftvia/API.md.", + }, + servers: [{ url: API_BASE_PATH }], + security: [{ cookieAuth: [] }], + tags: [ + { name: "Stammdaten", description: "Kunden und Standorte" }, + { name: "Aufträge", description: "Auftragsverwaltung" }, + { name: "Import", description: "Dokumentenimport mit KI-Extraktion" }, + { name: "Berichte", description: "Tages-/Abschlussberichte" }, + { name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" }, + { name: "Meta" }, + ], + paths, + components: { + securitySchemes: { + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "authjs.session-token", + description: "Auth.js-Session-Cookie (`authjs.session-token`, unter HTTPS `__Secure-authjs.session-token`). Rechte werden bei jedem Request aus der Datenbank gelesen.", + }, + }, + schemas, + responses, + parameters, + }, +}; + +/** Documented paths with full prefix, e.g. `/api/v1/work-orders/{id}/transition`. */ +export const API_ROUTES: string[] = Object.keys(paths).map((path) => `${API_BASE_PATH}${path}`); + +/** Documented operations as `METHOD /api/v1/...`. */ +export const API_OPERATIONS: string[] = Object.entries(paths).flatMap(([path, methods]) => Object.keys(methods).map((m) => `${m.toUpperCase()} ${API_BASE_PATH}${path}`)); From 5f08df324f382d6e44dc8691bcf2db9c34a22c86 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH 3/9] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20Migr?= =?UTF-8?q?ationen=20clientId=20je=20Mandant=20und=20KI-Token-Kontingent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 20260915090000_betrieb_client_id_per_tenant (Aufräumpunkt f, L8 offener Punkt 6): globale Unique-Indizes auf client_id (material_usages, work_sessions, time_entries, activity_notes, photos, voice_notes, reports, signatures) → @@unique([tenantId, clientId]). Offline-IDs sind nur je Mandant eindeutig; ein Replay derselben ID in einem anderen Mandanten scheiterte bisher mit internem Fehler. Keine neuen Tabellen. - 20260915091000_betrieb_ai_token_limit (Aufräumpunkt k): tenant_settings.ai_monthly_token_limit (NULL = Plattform-Vorgabe AI_MONTHLY_TOKEN_LIMIT, 0 = unbegrenzt). Keine neue Tabelle. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 53 +++++++++++++++++++ .../migration.sql | 7 +++ prisma/schema.prisma | 26 ++++++--- 3 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260915090000_betrieb_client_id_per_tenant/migration.sql create mode 100644 prisma/migrations/20260915091000_betrieb_ai_token_limit/migration.sql diff --git a/prisma/migrations/20260915090000_betrieb_client_id_per_tenant/migration.sql b/prisma/migrations/20260915090000_betrieb_client_id_per_tenant/migration.sql new file mode 100644 index 0000000..7247323 --- /dev/null +++ b/prisma/migrations/20260915090000_betrieb_client_id_per_tenant/migration.sql @@ -0,0 +1,53 @@ +-- L10b Betrieb & Aufräumen (Aufräumpunkt f, L8 offener Punkt 6): +-- Offline client ids are generated per device and only need to be unique within a tenant. +-- A global unique index let a replay with the same client id in another tenant fail with an +-- internal error (and leaked the existence of the id across tenants). Tables already carry +-- tenant RLS; no new tables. + +-- DropIndex +DROP INDEX "activity_notes_client_id_key"; + +-- DropIndex +DROP INDEX "material_usages_client_id_key"; + +-- DropIndex +DROP INDEX "photos_client_id_key"; + +-- DropIndex +DROP INDEX "reports_client_id_key"; + +-- DropIndex +DROP INDEX "signatures_client_id_key"; + +-- DropIndex +DROP INDEX "time_entries_client_id_key"; + +-- DropIndex +DROP INDEX "voice_notes_client_id_key"; + +-- DropIndex +DROP INDEX "work_sessions_client_id_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "activity_notes_tenant_id_client_id_key" ON "activity_notes"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "material_usages_tenant_id_client_id_key" ON "material_usages"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "photos_tenant_id_client_id_key" ON "photos"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "reports_tenant_id_client_id_key" ON "reports"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "signatures_tenant_id_client_id_key" ON "signatures"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "time_entries_tenant_id_client_id_key" ON "time_entries"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "voice_notes_tenant_id_client_id_key" ON "voice_notes"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_sessions_tenant_id_client_id_key" ON "work_sessions"("tenant_id", "client_id"); diff --git a/prisma/migrations/20260915091000_betrieb_ai_token_limit/migration.sql b/prisma/migrations/20260915091000_betrieb_ai_token_limit/migration.sql new file mode 100644 index 0000000..552b81c --- /dev/null +++ b/prisma/migrations/20260915091000_betrieb_ai_token_limit/migration.sql @@ -0,0 +1,7 @@ +-- L10b Betrieb & Aufräumen (Aufräumpunkt k, Spec §31 Kostenlimit): +-- Optional monthly AI token budget (input + output tokens of AiGeneration) per tenant. +-- NULL = platform default from env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited. +-- tenant_settings is already tenant-bound (RLS, TENANT_MODELS) — no new table. + +-- AlterTable +ALTER TABLE "tenant_settings" ADD COLUMN "ai_monthly_token_limit" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a89235a..bc29a6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -77,6 +77,8 @@ model TenantSettings { billingRecipients String[] @default([]) @map("billing_recipients") // Lotse (L9): "sie" | "du"; null = neutral without pronouns (Brandbook §9.2) lotseAddressForm String? @map("lotse_address_form") + // L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited + aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -856,7 +858,7 @@ model MaterialUsage { notes String? photoId String? @map("photo_id") recordedById String? @map("recorded_by_id") - clientId String? @unique @map("client_id") // offline local id + clientId String? @map("client_id") // offline local id createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -865,6 +867,7 @@ model MaterialUsage { workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull) @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("material_usages") } @@ -890,7 +893,7 @@ model WorkSession { startLng Float? @map("start_lng") startedOffline Boolean @default(false) @map("started_offline") deviceInfo String? @map("device_info") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -901,6 +904,7 @@ model WorkSession { @@index([tenantId, workOrderId]) @@index([tenantId, userId, status]) + @@unique([tenantId, clientId]) @@map("work_sessions") } @@ -925,7 +929,7 @@ model TimeEntry { corrected Boolean @default(false) correctionReason String? @map("correction_reason") correctedById String? @map("corrected_by_id") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -933,6 +937,7 @@ model TimeEntry { @@index([tenantId, workSessionId]) @@index([tenantId, userId, startedAt]) + @@unique([tenantId, clientId]) @@map("time_entries") } @@ -956,7 +961,7 @@ model ActivityNote { kind ActivityNoteKind @default(general) text String voiceNoteId String? @unique @map("voice_note_id") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -965,6 +970,7 @@ model ActivityNote { voiceNote VoiceNote? @relation(fields: [voiceNoteId], references: [id], onDelete: SetNull) @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("activity_notes") } @@ -1060,7 +1066,7 @@ model Photo { longitude Float? takenById String? @map("taken_by_id") includeInReport Boolean @default(true) @map("include_in_report") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) @@ -1068,6 +1074,7 @@ model Photo { photoRequirement PhotoRequirement? @relation(fields: [photoRequirementId], references: [id], onDelete: SetNull) @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("photos") } @@ -1090,7 +1097,7 @@ model VoiceNote { transcriptionModel String? @map("transcription_model") recordedById String? @map("recorded_by_id") recordedAt DateTime @map("recorded_at") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1098,6 +1105,7 @@ model VoiceNote { activityNote ActivityNote? @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("voice_notes") } @@ -1140,7 +1148,7 @@ model Report { approvedById String? @map("approved_by_id") approvedAt DateTime? @map("approved_at") rejectionReason String? @map("rejection_reason") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1150,6 +1158,7 @@ model Report { @@unique([lineageId, version]) @@index([tenantId, workOrderId]) @@index([tenantId, status]) + @@unique([tenantId, clientId]) @@map("reports") } @@ -1173,12 +1182,13 @@ model Signature { reason String? // required for absent/refused/later signedAt DateTime @map("signed_at") capturedById String? @map("captured_by_id") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") report Report @relation(fields: [reportId], references: [id], onDelete: Cascade) @@index([tenantId]) + @@unique([tenantId, clientId]) @@map("signatures") } From 85bae832d0077b7dbff6350a46e0687027fe6db2 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH 4/9] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20Sync?= =?UTF-8?q?=20=E2=80=93=20Berichts-Ops,=20Konflikt-=C3=9Cbernahme,=20eigen?= =?UTF-8?q?e=20Session=20im=20Bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aufräumpunkt j: report.save_draft und report.submit mit Zod-Schemas (lib/sync/ops.ts) und Registry-Einträgen → services/reports/sync-ops.ts. report.submit reicht baseVersion als expectedWorkOrderVersion und aiReviewed an submitReport durch; Lotse-Entwürfe ohne Bestätigung → rejected invalid. signature.capture bleibt unregistriert (Upload-Art für Unterschriftsbild fehlt). - Aufräumpunkt b: „Übernehmen" in der Konfliktliste delegiert an den Sync-Dispatcher (apply.ts#reapplyOperation, ohne baseVersion) statt des L2-Stubs; unterstützt work_order.transition und report.submit. Hinweistext der Konfliktliste angepasst. - Aufräumpunkt c: getFieldBundle liefert je Auftrag mySession (eigene aktive WorkSession); die Offline-Ansicht leitet den Zeitstatus daraus ab (alte Bundles: Näherung über Auftragsstatus). - scripts/test-betrieb-sync.ts (Bundle, clientId je Mandant, Berichts-Ops, Konflikt-Übernahme, Mandant B, Monteur ohne Zuweisung); test-einsatz-sync.ts prüft „nicht verfügbare Op" jetzt mit signature.capture, weil report.save_draft registriert ist. Co-Authored-By: Claude Opus 5 --- messages/de/workOrders.json | 2 +- messages/en/workOrders.json | 2 +- scripts/test-betrieb-sync.ts | 158 ++++++++++++++++++ scripts/test-einsatz-sync.ts | 5 +- src/lib/offline/bundle-core.ts | 12 +- src/lib/offline/types.ts | 2 + src/lib/sync/ops.ts | 25 ++- src/server/services/field/queries.ts | 20 ++- src/server/services/reports/sync-ops.ts | 42 +++++ src/server/services/sync/apply.ts | 55 +++++- src/server/services/sync/external-ops.ts | 6 +- .../services/work-orders/sync-reapply.ts | 27 +-- 12 files changed, 316 insertions(+), 40 deletions(-) create mode 100644 scripts/test-betrieb-sync.ts create mode 100644 src/server/services/reports/sync-ops.ts diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 3c02e50..0cb6433 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -406,7 +406,7 @@ "applied": "Übernommen.", "discarded": "Verworfen.", "applyHint": "Übernehmen wendet den Vorgang erneut auf den aktuellen Stand an – im Namen der Person, die ihn erfasst hat.", - "scopeHint": "Übernehmen ist derzeit nur für Statusänderungen möglich; andere Vorgänge bitte im Auftrag nacharbeiten." + "scopeHint": "Übernehmen ist für Statusänderungen und abgesendete Berichte möglich; andere Vorgänge bitte im Auftrag nacharbeiten." }, "errors": { "not_found": "Nicht gefunden oder keine Berechtigung.", diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 24e6292..166e853 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -406,7 +406,7 @@ "applied": "Applied.", "discarded": "Discarded.", "applyHint": "Apply re-runs the operation against the current state – on behalf of the person who recorded it.", - "scopeHint": "Apply currently supports status changes only; please rework other operations in the order." + "scopeHint": "Apply supports status changes and submitted reports; please rework other operations in the order." }, "errors": { "not_found": "Not found or no permission.", diff --git a/scripts/test-betrieb-sync.ts b/scripts/test-betrieb-sync.ts new file mode 100644 index 0000000..5eff294 --- /dev/null +++ b/scripts/test-betrieb-sync.ts @@ -0,0 +1,158 @@ +// Lane L10b „Betrieb & Aufräumen" — Sync-Aufräumpunkte: +// b) Konflikt „Übernehmen" für report.submit (Dispatcher statt L2-Stub) +// c) Bundle mit eigener laufender WorkSession je Auftrag (+ Offline-Ansicht nutzt sie) +// f) clientId eindeutig je Mandant (@@unique([tenantId, clientId])) +// j) Sync-Ops report.save_draft / report.submit inkl. aiReviewed (Lotse-Freigabeprinzip) +// Jeweils mit Mandantentrennung (B) und Scope (Monteur ohne Zuweisung). +// +// Lauf: npx tsx scripts/test-betrieb-sync.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { closeJobQueues } from "../src/server/jobs/queues"; +import { applyOperations, reapplyOperation } from "../src/server/services/sync/apply"; +import { getFieldBundle } from "../src/server/services/field/queries"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { applySyncConflict } from "../src/server/services/work-orders/conflicts"; +import { initialSession } from "../src/lib/offline/bundle-core"; +import { ROLE_DEFS } from "../src/server/rbac"; +import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; +import type { ServiceCtx } from "../src/server/services/context"; +import { createFixture, ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +function op(opType: SyncOpType, payload: Record, extra: Partial = {}): SyncOperationInput { + return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra }; +} + +async function one(ctx: ServiceCtx, operation: SyncOperationInput) { + const res = await applyOperations(ctx, { deviceId: "l10b-device", operations: [operation] }); + return res.results[0]; +} + +const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version; + +async function main() { + const f = await createFixture("l10bsync"); + const wo = f.orderA.id; + const cleanupReports = async () => { + await prisma.report.deleteMany({ where: { tenantId: { in: [f.tenantA.id, f.tenantB.id] } } }); + }; + try { + // backoffice user in tenant A (resolves conflicts) + const officeIdentity = await prisma.identity.upsert({ where: { email: "office@zz-l10bsync.test" }, update: {}, create: { email: "office@zz-l10bsync.test", passwordHash: "x" } }); + const office = await prisma.user.create({ data: { tenantId: f.tenantA.id, identityId: officeIdentity.id, email: "office@zz-l10bsync.test", name: "Office A" } }); + const ctxOffice = ctxFor(f.tenantA.id, office.id, "backoffice"); + const ctxOfficeB = ctxFor(f.tenantB.id, f.techB.id, "backoffice"); + // „Übernehmen" loads the device user's permissions from the DB → give the technician a real role + const techPerms = await prisma.permission.findMany({ where: { key: { in: [...ROLE_DEFS.technician.permissions] } }, select: { id: true } }); + const techRole = await prisma.role.create({ + data: { tenantId: f.tenantA.id, key: "technician", name: ROLE_DEFS.technician.name, rolePermissions: { create: techPerms.map((p) => ({ permissionId: p.id })) } }, + }); + await prisma.userRole.create({ data: { userId: f.tech.id, roleId: techRole.id } }); + + console.log("\n— c) Bundle: eigene laufende Session —"); + const acc = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: await version(wo) })); + ok(acc.status === "applied", "Auftrag angenommen"); + const sessionClientId = randomUUID(); + const start = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: sessionClientId })); + ok(start.status === "applied", "Session gestartet (Sync)"); + const techBundle = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); + ok(techBundle?.mySession?.status === "running" && techBundle.mySession.id === start.idMap?.[sessionClientId], "Bundle des Monteurs: mySession = laufende eigene Session"); + const leadBundle = (await getFieldBundle(f.ctxLead)).orders.find((o) => o.id === wo); + ok(!!leadBundle && leadBundle.mySession === null, "Teamleiter sieht den Auftrag, aber keine eigene Session (nicht aus dem Status abgeleitet)"); + ok(initialSession({ status: "in_progress", mySession: null }) === null, "Offline-Ansicht: in Arbeit ohne eigene Session → keine Zeitaktion Pause/Ende"); + ok(initialSession({ status: "in_progress", mySession: { id: "s", status: "paused", startedAt: "" } }) === "paused", "Offline-Ansicht: eigene Session pausiert"); + ok(initialSession({ status: "en_route" }) === "en_route", "Offline-Ansicht: altes Bundle ohne mySession → Näherung über Status"); + await one(f.ctxTech, op("session.pause", { workOrderId: wo })); + const paused = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); + ok(paused?.mySession?.status === "paused", "nach Pause: mySession paused"); + await one(f.ctxTech, op("session.resume", { workOrderId: wo })); + ok(!(await getFieldBundle(f.ctxB)).orders.some((o) => o.id === wo), "Mandant B: Auftrag von A nicht im Bundle"); + ok(!(await getFieldBundle(f.ctxOutsider)).orders.some((o) => o.id === wo), "Monteur ohne Zuweisung: Auftrag nicht im Bundle"); + + console.log("\n— f) clientId je Mandant —"); + const sameSession = await one(f.ctxB, op("session.start", { workOrderId: f.orderB.id, mode: "work", clientId: sessionClientId })); + ok(sameSession.status === "applied" && !!sameSession.idMap?.[sessionClientId] && sameSession.idMap[sessionClientId] !== start.idMap?.[sessionClientId], "gleiche Session-clientId in Mandant B → eigene Session (kein interner Fehler)"); + const noteClientId = randomUUID(); + const nA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A" })); + const nB = await one(f.ctxB, op("note.create", { workOrderId: f.orderB.id, clientId: noteClientId, kind: "general", text: "B" })); + ok(nA.status === "applied" && nB.status === "applied" && nA.idMap?.[noteClientId] !== nB.idMap?.[noteClientId], "gleiche Notiz-clientId in A und B → zwei Notizen"); + const replayA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A nochmal" })); + ok(replayA.status === "applied" && replayA.idMap?.[noteClientId] === nA.idMap?.[noteClientId], "Wiederholung in A (neue clientOpId) → dieselbe Notiz (Idempotenz je Mandant)"); + ok((await prisma.activityNote.count({ where: { clientId: noteClientId } })) === 2, "genau eine Notiz je Mandant"); + let dupRejected = false; + try { + await prisma.workSession.create({ data: { tenantId: f.tenantA.id, workOrderId: wo, userId: f.tech.id, status: "ended", startedAt: new Date(), endedAt: new Date(), clientId: sessionClientId } }); + } catch (err) { + dupRejected = (err as { code?: string }).code === "P2002"; + } + ok(dupRejected, "DB: doppelte clientId im selben Mandanten → Unique-Verletzung"); + + console.log("\n— j) report.save_draft / report.submit —"); + const { report } = await createDailyReport(f.ctxTech, { workOrderId: wo }); + await prisma.report.update({ where: { id: report.id }, data: { aiDrafted: true } }); // Lotse-Entwurf simulieren + const saved = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo, reportId: report.id, texts: { workPerformed: "Heizkörper montiert und entlüftet" } })); + const afterSave = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(saved.status === "applied" && (afterSave.content as { texts: { workPerformed: string } }).texts.workPerformed === "Heizkörper montiert und entlüftet", "report.save_draft → Texte gespeichert"); + const badPayload = await one(f.ctxTech, op("report.submit", { workOrderId: wo }, { baseVersion: await version(wo) })); + ok(badPayload.status === "rejected" && badPayload.errorCode === "invalid", "report.submit ohne reportId → rejected invalid"); + + const noReview = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id }, { baseVersion: await version(wo) })); + ok(noReview.status === "rejected" && noReview.errorCode === "invalid" && /reviewed/.test(noReview.message ?? ""), "Lotse-Entwurf offline ohne aiReviewed → rejected invalid"); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "Bericht bleibt Entwurf"); + + const foreignB = await one(f.ctxB, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); + ok(foreignB.status === "rejected" && foreignB.errorCode === "not_found", "Mandant B: report.submit auf A → not_found"); + const outsider = await one(f.ctxOutsider, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); + ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: report.submit → not_found"); + const mismatch = await one(f.ctxB, op("report.save_draft", { workOrderId: f.orderB.id, reportId: report.id, texts: { hints: "x" } })); + ok(mismatch.status === "rejected" && mismatch.errorCode === "not_found", "Mandant B: Bericht von A über eigenen Auftrag → not_found"); + + const current = await version(wo); + const stale = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: current - 1 })); + ok(stale.status === "conflict" && stale.entityVersion === current, "veraltete baseVersion → conflict"); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "bei Konflikt nichts abgesendet"); + const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } }); + ok(conflictOp.status === "conflict" && conflictOp.opType === "report.submit", "Konflikt für die Backoffice-Liste gespeichert"); + + console.log("\n— b) Konflikt übernehmen (report.submit) —"); + await expectCode(() => applySyncConflict(ctxOfficeB, conflictOp.id), "not_found", "Mandant B kann den Konflikt von A nicht übernehmen"); + await expectCode(() => applySyncConflict(f.ctxTech, conflictOp.id), "forbidden", "Monteur (ohne work_order:write) kann Konflikte nicht übernehmen"); + await expectCode(() => reapplyOperation(f.ctxTech, { opType: "note.create", entityId: wo, payload: { workOrderId: wo, kind: "general", text: "x" } }), "invalid", "Übernehmen nur für konfliktbehaftete Ops"); + const taken = await applySyncConflict(ctxOffice, conflictOp.id); + const submitted = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(submitted.status === "submitted" && typeof taken.entityVersion === "number", "Übernehmen → Bericht abgesendet (als Gerätenutzer, aiReviewed aus der Op)"); + const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } }); + ok(resolved.status === "applied" && resolved.resolvedById === office.id, "Konflikt als übernommen markiert (resolvedBy Backoffice)"); + await expectCode(() => applySyncConflict(ctxOffice, conflictOp.id), "not_found", "zweites Übernehmen → not_found"); + + // transition conflicts keep working through the dispatcher + const staleTransition = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "paused" }, { baseVersion: 1 })); + ok(staleTransition.status === "conflict", "Statuswechsel mit veralteter Version → conflict"); + const tOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: staleTransition.clientOpId } }); + const statusBefore = (await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status; + const refused = await applySyncConflict(ctxOffice, tOp.id).then( + () => null, + (err: { code?: string }) => err.code ?? "error", + ); + ok(refused === "invalid" || refused === "forbidden", `Übernehmen gegen aktuellen Stand: unzulässiger Übergang wird abgelehnt (${refused})`); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status === statusBefore, "… Auftragsstatus unverändert, Konflikt bleibt offen"); + ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: tOp.id } })).status === "conflict", "… SyncOperation weiterhin conflict"); + } finally { + await cleanupReports().catch((e) => console.error("report cleanup failed", e)); + await f.cleanup().catch((e) => console.error("cleanup failed", e)); + await closeJobQueues(); + await prisma.$disconnect(); + } +} + +main() + .catch((err) => { + console.error(err); + ok(false, `unerwarteter Fehler: ${(err as Error).message}`); + }) + .finally(() => { + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Sync-Aufräumprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-einsatz-sync.ts b/scripts/test-einsatz-sync.ts index 04b06a3..4ed2651 100644 --- a/scripts/test-einsatz-sync.ts +++ b/scripts/test-einsatz-sync.ts @@ -82,8 +82,9 @@ async function main() { ok(reuseOther.status === "rejected" && !reuseOther.idMap, "fremder Nutzer mit gleicher clientOpId erhält kein gespeichertes Ergebnis"); console.log("\n— Ops fremder Lanes —"); - const report = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo })); - ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "report.save_draft ohne L5 → rejected invalid mit Hinweis"); + // L10b: report.save_draft/report.submit are registered now (test-betrieb-sync.ts); signature.capture is still unregistered + const report = await one(f.ctxTech, op("signature.capture", { workOrderId: wo })); + ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "signature.capture ohne Implementierung → rejected invalid mit Hinweis"); ok((await prisma.syncOperation.count({ where: { clientOpId: report.clientOpId } })) === 0, "nicht verfügbare Op wird nicht gespeichert (später wiederholbar)"); console.log("\n— Uploads & Mandantentrennung —"); diff --git a/src/lib/offline/bundle-core.ts b/src/lib/offline/bundle-core.ts index 0e08616..9917b24 100644 --- a/src/lib/offline/bundle-core.ts +++ b/src/lib/offline/bundle-core.ts @@ -64,7 +64,7 @@ const str = (v: unknown): string | null => (typeof v === "string" ? v : null); /** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */ export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView { const data: BundleOrderData = structuredCloneSafe(record.data); - const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data.status), pendingOps: 0, conflict: false, rejected: false } }; + const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), pendingOps: 0, conflict: false, rejected: false } }; const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq); for (const op of mine) { @@ -79,8 +79,14 @@ export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderV return view; } -function initialSession(status: string): SessionState { - // The bundle carries no sessions; the order status is the best local approximation. +export function initialSession(data: Pick): SessionState { + // L10b: bundles carry the caller's own active session — exact also on team orders. + if (data.mySession !== undefined) { + const s = data.mySession?.status; + return s === "en_route" || s === "running" || s === "paused" ? s : null; + } + // Bundles stored before L10b: the order status is the best local approximation. + const status = data.status; if (status === "en_route") return "en_route"; if (status === "in_progress") return "running"; if (status === "paused") return "paused"; diff --git a/src/lib/offline/types.ts b/src/lib/offline/types.ts index 3828eb3..eb52769 100644 --- a/src/lib/offline/types.ts +++ b/src/lib/offline/types.ts @@ -92,6 +92,8 @@ export type BundleOrderData = { scope?: string | null; technicianNotes?: string | null; signatureRequired?: boolean; + /** L10b: own active work session of the signed-in user (absent in bundles stored before L10b) */ + mySession?: { id: string; status: string; startedAt: string } | null; orderType?: { name: string } | null; customer: { id?: string; diff --git a/src/lib/sync/ops.ts b/src/lib/sync/ops.ts index 6bcd6e6..e09d0c3 100644 --- a/src/lib/sync/ops.ts +++ b/src/lib/sync/ops.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { SyncOpType } from "./envelope"; import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; import { emergencyCreatePayload } from "@/lib/emergency/schemas"; +import { reportTextsSchema } from "@/lib/reports/content"; /** * Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to @@ -113,7 +114,25 @@ export const voiceAttachPayload = z.object({ kind: z.enum(NOTE_KINDS).optional(), }); -/** Schemas of ops owned by other lanes are validated there (reports: L5, emergency: L8). */ +/** + * report.save_draft / report.submit (L10b, registered in services/sync/external-ops.ts → + * services/reports/sync-ops.ts). `workOrderId` is required: the sync pipeline checks scope and the + * conflict version (`baseVersion` = WorkOrder.version seen by the device) on that order. + */ +export const reportSaveDraftPayload = z.object({ + workOrderId: id, + reportId: id, + texts: reportTextsSchema.partial(), +}); + +export const reportSubmitPayload = z.object({ + workOrderId: id, + reportId: id, + /** L9 Freigabeprinzip: "Ich habe den Vorschlag vom Lotsen geprüft" — mandatory for Lotse drafts */ + aiReviewed: z.boolean().optional(), +}); + +/** Schemas of ops owned by other lanes are validated there (signature.capture: not offline-capable yet). */ const passthrough = z.record(z.string(), z.unknown()); export const OP_PAYLOAD_SCHEMAS = { @@ -127,8 +146,8 @@ export const OP_PAYLOAD_SCHEMAS = { "material.upsert": materialUpsertPayload, "photo.attach": photoAttachPayload, "voice.attach": voiceAttachPayload, - "report.save_draft": passthrough, - "report.submit": passthrough, + "report.save_draft": reportSaveDraftPayload, + "report.submit": reportSubmitPayload, "signature.capture": passthrough, "emergency.create": emergencyCreatePayload, } satisfies Record; diff --git a/src/server/services/field/queries.ts b/src/server/services/field/queries.ts index b106916..48d07c6 100644 --- a/src/server/services/field/queries.ts +++ b/src/server/services/field/queries.ts @@ -301,9 +301,27 @@ export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) { const histories = Object.fromEntries( await Promise.all(siteIds.map(async (id) => [id, await fieldSiteHistory(ctx, id, 5).catch(() => [])] as const)), ); + // L10b (L7 offene Punkte 3/4): the caller's own running session per order, so the offline view + // shows the correct time actions on team orders with several technicians. + const ownSessions = orders.length + ? await ctx.db.workSession.findMany({ + where: { workOrderId: { in: orders.map((o) => o.id) }, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES } }, + orderBy: { startedAt: "desc" }, + select: { id: true, workOrderId: true, status: true, startedAt: true }, + }) + : []; + const mySessions = new Map(); + for (const s of ownSessions) { + if (!mySessions.has(s.workOrderId)) mySessions.set(s.workOrderId, { id: s.id, status: s.status, startedAt: s.startedAt.toISOString() }); + } return { serverTime: serverTime.toISOString(), since: since?.toISOString() ?? null, - orders: orders.map((o) => ({ ...o, statusGroup: STATUS_GROUP[o.status], siteHistory: o.site ? histories[o.site.id] ?? [] : [] })), + orders: orders.map((o) => ({ + ...o, + statusGroup: STATUS_GROUP[o.status], + siteHistory: o.site ? histories[o.site.id] ?? [] : [], + mySession: mySessions.get(o.id) ?? null, + })), }; } diff --git a/src/server/services/reports/sync-ops.ts b/src/server/services/reports/sync-ops.ts new file mode 100644 index 0000000..dce4cc8 --- /dev/null +++ b/src/server/services/reports/sync-ops.ts @@ -0,0 +1,42 @@ +import type { SyncOperationInput } from "@/lib/sync/envelope"; +import { reportSaveDraftPayload, reportSubmitPayload } from "@/lib/sync/ops"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ExternalOpResult } from "@/server/services/sync/external-ops"; +import { requireVisibleReport } from "./common"; +import { updateReportTexts } from "./edit"; +import { submitReport } from "./submit"; + +/** + * Sync ops of the reports module (L10b, ARCHITEKTUR §4.6), dispatched by services/sync/apply.ts: + * - `report.save_draft` → updateReportTexts (additive, no conflict check) + * - `report.submit` → submitReport with `expectedWorkOrderVersion = op.baseVersion` and the + * Lotse review confirmation `aiReviewed` passed through (otherwise Lotse drafts are rejected + * with `invalid` / details.field = "aiReviewed"). + * Scope and version pre-check happen in apply.ts on `payload.workOrderId`; the report must belong + * to exactly that order, otherwise the check would have run against the wrong entity. + */ +export async function applySyncOp(ctx: ServiceCtx, op: SyncOperationInput): Promise { + switch (op.opType) { + case "report.save_draft": { + const p = reportSaveDraftPayload.parse(op.payload); + await requireReportOfOrder(ctx, p.reportId, p.workOrderId); + await updateReportTexts(ctx, { reportId: p.reportId, texts: p.texts }); + return {}; + } + case "report.submit": { + const p = reportSubmitPayload.parse(op.payload); + await requireReportOfOrder(ctx, p.reportId, p.workOrderId); + await submitReport(ctx, { reportId: p.reportId, expectedWorkOrderVersion: op.baseVersion, aiReviewed: p.aiReviewed }); + const wo = await ctx.db.workOrder.findFirst({ where: { id: p.workOrderId }, select: { version: true } }); + return { entityVersion: wo?.version }; + } + default: + throw new ServiceError("invalid", `operation ${op.opType} is not handled by reports`); + } +} + +async function requireReportOfOrder(ctx: ServiceCtx, reportId: string, workOrderId: string) { + const report = await requireVisibleReport(ctx, reportId); + if (report.workOrderId !== workOrderId) throw new ServiceError("invalid", "report does not belong to work order", { field: "reportId" }); + return report; +} diff --git a/src/server/services/sync/apply.ts b/src/server/services/sync/apply.ts index b44fa08..de9aaf6 100644 --- a/src/server/services/sync/apply.ts +++ b/src/server/services/sync/apply.ts @@ -62,6 +62,52 @@ const FIELD_HANDLERS: Partial> = { class NotAvailable extends Error {} +/** Route a validated op to the field handler or the registered module of another lane. */ +async function dispatch(ctx: ServiceCtx, payload: unknown, op: SyncOperationInput): Promise { + const handler = FIELD_HANDLERS[op.opType]; + if (handler) return handler(ctx, payload, op); + const load = EXTERNAL_OPS[op.opType]; + const external = load ? await load() : null; + if (!external) throw new NotAvailable(`operation ${op.opType} is not available yet (lane ${EXTERNAL_OP_OWNERS[op.opType] ?? "unknown"})`); + return external(ctx, op); +} + +/** + * Backoffice „Übernehmen" of a stored conflict (services/work-orders/conflicts.ts, L10b): the op + * is dispatched again against the CURRENT state — same payload validation and domain services as + * the sync path, but without the baseVersion comparison. Only conflict-prone ops + * (`work_order.transition`, `report.submit`) can end up as conflicts. + */ +export async function reapplyOperation( + ctx: ServiceCtx, + stored: { opType: string; entityType?: string | null; entityId: string | null; payload: unknown }, +): Promise { + const opType = stored.opType as SyncOpType; + if (!CONFLICTING_OPS.includes(opType)) throw new ServiceError("invalid", "reapply_unsupported", { opType: stored.opType }); + const payload: Record = { ...((stored.payload ?? {}) as Record) }; + // stored ops whose work order is only referenced via entityId (accepted by the former L2 stub) + if (typeof payload.workOrderId !== "string" && stored.entityId && (!stored.entityType || stored.entityType === "work_order")) { + payload.workOrderId = stored.entityId; + } + const parsed = OP_PAYLOAD_SCHEMAS[opType].safeParse(payload); + if (!parsed.success) throw new ServiceError("invalid", "sync_payload_invalid"); + const op: SyncOperationInput = { + clientOpId: "00000000-0000-4000-8000-000000000000", // not used by handlers; idempotency stays with the stored op + opType, + entityType: stored.entityType ?? undefined, + entityId: stored.entityId ?? undefined, + baseVersion: undefined, + payload, + clientCreatedAt: new Date().toISOString(), + }; + try { + return await dispatch(ctx, parsed.data, op); + } catch (err) { + if (err instanceof NotAvailable) throw new ServiceError("invalid", "reapply_unsupported", { opType }); + throw err; + } +} + function workOrderIdOf(op: SyncOperationInput): string | undefined { const fromPayload = (op.payload as { workOrderId?: unknown }).workOrderId; if (typeof fromPayload === "string") return fromPayload; @@ -149,14 +195,7 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu } // 4. dispatch - let handler = FIELD_HANDLERS[op.opType]; - if (!handler) { - const load = EXTERNAL_OPS[op.opType]; - const external = load ? await load() : null; - if (!external) throw new NotAvailable(`operation ${op.opType} is not available yet (lane ${EXTERNAL_OP_OWNERS[op.opType] ?? "unknown"})`); - handler = (c, _payload, o) => external(c, o); - } - const result = await handler(ctx, parsed.data, op); + const result = await dispatch(ctx, parsed.data, op); const rec = await record(ctx, op, deviceId, "applied", { ...result }); if (rec === "duplicate") return { ...base, status: "duplicate", ...result }; return { ...base, status: "applied", ...result }; diff --git a/src/server/services/sync/external-ops.ts b/src/server/services/sync/external-ops.ts index 61ef872..ea593d8 100644 --- a/src/server/services/sync/external-ops.ts +++ b/src/server/services/sync/external-ops.ts @@ -23,8 +23,8 @@ export const EXTERNAL_OP_OWNERS: Partial> = { }; export const EXTERNAL_OPS: Partial Promise>> = { - // lane-reports: "report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), - // lane-reports: "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), - // lane-reports: "signature.capture": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), + "report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // L10b + "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // L10b + // not registered: "signature.capture" — needs a signature image upload kind in /api/v1/uploads first (see docs/craftvia/lanes/betrieb.md) "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp), }; diff --git a/src/server/services/work-orders/sync-reapply.ts b/src/server/services/work-orders/sync-reapply.ts index bfc603f..ea0c527 100644 --- a/src/server/services/work-orders/sync-reapply.ts +++ b/src/server/services/work-orders/sync-reapply.ts @@ -1,25 +1,16 @@ -import { ServiceError, type ServiceCtx } from "@/server/services/context"; -import { transitionWorkOrder } from "@/server/services/work-orders/transition"; +import type { ServiceCtx } from "@/server/services/context"; +import { reapplyOperation } from "@/server/services/sync/apply"; /** - * STUB (lane L2) until lane L4 delivers `src/server/services/sync/apply.ts`. - * Contract (ARCHITEKTUR §4.6): re-dispatch a stored SyncOperation onto the domain services - * against the CURRENT state (no baseVersion). Replace the body with a call to the L4 dispatcher - * after merge; the signature stays. - * - * MVP scope of the stub: only `work_order.transition` (the only conflict-prone op besides - * `report.submit`, which belongs to lane reports). + * Re-dispatch a stored conflicting SyncOperation against the CURRENT state (ARCHITEKTUR §4.6), + * as the original device user. L10b: delegates to the L4 dispatcher (the former L2 stub only knew + * `work_order.transition`). Supported: `work_order.transition` and `report.submit` (incl. the + * stored Lotse review confirmation `aiReviewed`). Everything else → `invalid reapply_unsupported`. */ export async function reapplySyncOperation( opCtx: ServiceCtx, - op: { opType: string; entityId: string | null; payload: unknown }, + op: { opType: string; entityType?: string | null; entityId: string | null; payload: unknown }, ): Promise<{ entityVersion?: number }> { - if (op.opType === "work_order.transition") { - const p = (op.payload ?? {}) as { to?: string; reason?: string; workOrderId?: string }; - const workOrderId = op.entityId ?? p.workOrderId; - if (!workOrderId || !p.to) throw new ServiceError("invalid", "sync_payload_invalid"); - const res = await transitionWorkOrder(opCtx, { workOrderId, to: p.to as never, reason: p.reason ?? null }); - return { entityVersion: res.version }; - } - throw new ServiceError("invalid", "reapply_unsupported", { opType: op.opType }); + const result = await reapplyOperation(opCtx, op); + return { entityVersion: result.entityVersion }; } From 8aedc642ca75d9864c369a17d060635b8920bc6a Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH 5/9] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20Audi?= =?UTF-8?q?t=20nach=20Commit,=20mergeCustomers=20atomar,=20Audit-Aktion=20?= =?UTF-8?q?read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aufräumpunkt h: writeAuditLog puffert innerhalb von inTransaction (AsyncLocalStorage) und schreibt nach dem Commit; bei Rollback werden die Einträge verworfen, nur „denied" bleibt. Verschachtelte Transaktionen nutzen den äußeren Puffer. - Aufräumpunkt d: mergeCustomers läuft über inTransaction (sequenziell, geschützter Statuswechsel) statt ctx.db.$transaction([...]) und ist damit auch bei RLS_ENFORCED=true atomar und in äußere Transaktionen einbettbar. - Aufräumpunkt e: AuditAction „read" (+ Label im Audit-Viewer de/en); Notdienst-Kunden- und Objektsuche protokollieren als „read" statt „export". Co-Authored-By: Claude Opus 5 --- messages/de/notifications.json | 1 + messages/en/notifications.json | 1 + src/server/audit.ts | 92 ++++++++++++++++++++----- src/server/services/context.ts | 5 +- src/server/services/customers/merge.ts | 83 ++++++++++++---------- src/server/services/emergency/lookup.ts | 4 +- 6 files changed, 129 insertions(+), 57 deletions(-) diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 4262b25..3689c31 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -147,6 +147,7 @@ "logout": "Abmeldung", "denied": "Abgelehnt", "export": "Export", + "read": "Lesezugriff", "import": "Import", "provision": "Eingerichtet", "approve": "Freigegeben", diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 2bd8e15..3206fdf 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -147,6 +147,7 @@ "logout": "Sign-out", "denied": "Denied", "export": "Export", + "read": "Read access", "import": "Import", "provision": "Provisioned", "approve": "Approved", diff --git a/src/server/audit.ts b/src/server/audit.ts index 75d82a7..05ab060 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { prisma } from "./db"; /** @@ -6,14 +7,28 @@ import { prisma } from "./db"; * filtered, and tenantId is passed explicitly by the caller. */ -type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied"; +/** `read` (L10b): sensitive read access that must be traceable (e.g. emergency customer search). */ +export type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied" | "read"; + +type RequestContext = { ipAddress: string | null; userAgent: string | null }; + +type AuditEntry = { + tenantId: string; + actorId?: string; + action: AuditAction; + scope?: "tenant" | "platform"; + entity: string; + entityId?: string; + before?: unknown; + after?: unknown; +}; /** * IP address and user agent of the current request, if there is one. * Outside a request scope (workers, scripts, tests) `headers()` throws → both null. * Behind the Coolify/Traefik proxy the client IP is the first X-Forwarded-For hop. */ -async function requestContext(): Promise<{ ipAddress: string | null; userAgent: string | null }> { +async function requestContext(): Promise { try { const { headers } = await import("next/headers"); const h = await headers(); @@ -26,18 +41,8 @@ async function requestContext(): Promise<{ ipAddress: string | null; userAgent: } } -export async function writeAuditLog(entry: { - tenantId: string; - actorId?: string; - action: AuditAction; - scope?: "tenant" | "platform"; - entity: string; - entityId?: string; - before?: unknown; - after?: unknown; -}) { - const ctx = await requestContext(); - await prisma.auditLog.create({ +function insertAudit(entry: AuditEntry, request: RequestContext) { + return prisma.auditLog.create({ data: { tenantId: entry.tenantId, scope: entry.scope ?? "tenant", @@ -47,12 +52,67 @@ export async function writeAuditLog(entry: { entityId: entry.entityId, before: entry.before as object | undefined, after: entry.after as object | undefined, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, + ipAddress: request.ipAddress, + userAgent: request.userAgent, }, }); } +/** + * L10b (ARCHITEKTUR §4.8): audit entries written inside `inTransaction` are buffered and flushed + * after the commit. The audit insert uses the owner client (outside the tenant transaction), so + * without the buffer a rolled-back transaction would leave "create/update" entries for changes + * that never happened. On rollback only `denied` entries are kept (security relevant, independent + * of the business change). + */ +const deferredAudit = new AsyncLocalStorage<{ entries: { entry: AuditEntry; request: RequestContext }[] }>(); + +export async function writeAuditLog(entry: AuditEntry) { + const request = await requestContext(); + const buffer = deferredAudit.getStore(); + if (buffer) { + // snapshot before/after now — callers may mutate the objects after the call + buffer.entries.push({ entry: structuredCloneSafe(entry), request }); + return; + } + await insertAudit(entry, request); +} + +/** Run `fn` with deferred audit writes (see above). Nested calls join the outer buffer. */ +export async function withDeferredAudit(fn: () => Promise): Promise { + if (deferredAudit.getStore()) return fn(); + const buffer: { entries: { entry: AuditEntry; request: RequestContext }[] } = { entries: [] }; + let result: T; + try { + result = await deferredAudit.run(buffer, fn); + } catch (err) { + await flush(buffer.entries.filter((e) => e.entry.action === "denied")); + throw err; + } + await flush(buffer.entries); + return result; +} + +async function flush(entries: { entry: AuditEntry; request: RequestContext }[]) { + for (const { entry, request } of entries) { + try { + await insertAudit(entry, request); + } catch (err) { + // the business change is already committed — never turn it into an error for the caller + console.error("[audit] deferred write failed:", (err as Error).message); + } + } +} + +/** Deep copy for JSON-like audit payloads; falls back to the original for non-cloneable values. */ +function structuredCloneSafe(value: T): T { + try { + return structuredClone(value); + } catch { + return value; + } +} + /** * Audit-Eintrag der Plattform-Ebene (kein Mandantenbezug, scope="platform"). * Für Superadmin-Anmeldungen und -Aktionen (Phase-1-Härtung Paket 2). diff --git a/src/server/services/context.ts b/src/server/services/context.ts index 4e4eaa1..05c2d84 100644 --- a/src/server/services/context.ts +++ b/src/server/services/context.ts @@ -1,5 +1,6 @@ import type { Session } from "next-auth"; import { tenantTransaction, type TenantDb } from "@/server/db"; +import { withDeferredAudit } from "@/server/audit"; /** * Context passed to every domain service. Created by server actions (from moduleGuard) @@ -27,9 +28,11 @@ export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: R * Run a multi-step write atomically (ARCHITEKTUR §4.8). `fn` receives a ctx whose `db` * is bound to the transaction; nested calls join the outer transaction. * Never use `ctx.db.$transaction(...)` directly — it is not atomic with RLS_ENFORCED=true. + * Audit entries written inside `fn` are flushed after the commit and dropped on rollback + * (except `denied`), see audit.ts#withDeferredAudit. */ export function inTransaction(ctx: ServiceCtx, fn: (ctx: ServiceCtx) => Promise): Promise { - return tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx })); + return withDeferredAudit(() => tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx }))); } export function can(ctx: ServiceCtx, permission: string): boolean { diff --git a/src/server/services/customers/merge.ts b/src/server/services/customers/merge.ts index 206018e..481fb83 100644 --- a/src/server/services/customers/merge.ts +++ b/src/server/services/customers/merge.ts @@ -1,5 +1,5 @@ import { writeAuditLog } from "@/server/audit"; -import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; import { mergeSchema, type MergeInput } from "@/server/services/customers/schemas"; /** @@ -7,51 +7,58 @@ import { mergeSchema, type MergeInput } from "@/server/services/customers/schema * Contacts, sites, work orders and documents of the source are moved to the target; the source * becomes status `merged` with `mergedIntoId`. Both records must belong to the caller's tenant * (dbForTenant) — ids of another tenant are "not found". Never triggered automatically. + * Runs in `inTransaction` (L10b): atomic with RLS_ENFORCED=true and joinable by callers that + * already hold a transaction (e.g. the emergency review). */ export async function mergeCustomers(ctx: ServiceCtx, input: MergeInput) { assertCan(ctx, "customer:merge"); const { sourceId, targetId } = mergeSchema.parse(input); - const [source, target] = await Promise.all([ - ctx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }), - ctx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }), - ]); - if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" }); - if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" }); - if (source.status === "merged" || target.status === "merged") { - throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" }); - } + return inTransaction(ctx, async (tx) => { + const [source, target] = await Promise.all([ + tx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }), + tx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }), + ]); + if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" }); + if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" }); + if (source.status === "merged" || target.status === "merged") { + throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" }); + } - const [contacts, sites, workOrders, documents, mergedSource] = await ctx.db.$transaction([ - ctx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), - ctx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), + // sequential on purpose: one interactive transaction, no parallel queries on the tx client + const contacts = await tx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }); + const sites = await tx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }); // version bump: offline clients must not overwrite the re-parented order with stale data - ctx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }), - ctx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), - ctx.db.customer.update({ - where: { id: sourceId }, + const workOrders = await tx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }); + const documents = await tx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }); + // guarded update: a concurrent merge of the same source loses instead of merging twice + const flipped = await tx.db.customer.updateMany({ + where: { id: sourceId, status: { not: "merged" } }, data: { status: "merged", mergedIntoId: targetId, isProvisional: false }, - }), - ]); + }); + if (flipped.count !== 1) throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" }); + const mergedSource = await tx.db.customer.findFirstOrThrow({ where: { id: sourceId } }); - const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count }; - await writeAuditLog({ - tenantId: ctx.tenantId, - actorId: ctx.userId, - action: "update", - entity: "customer", - entityId: sourceId, - before: source, - after: { ...mergedSource, merge: { role: "source", targetId, moved } }, + const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count }; + // deferred until commit by inTransaction (audit.ts#withDeferredAudit) + await writeAuditLog({ + tenantId: tx.tenantId, + actorId: tx.userId, + action: "update", + entity: "customer", + entityId: sourceId, + before: source, + after: { ...mergedSource, merge: { role: "source", targetId, moved } }, + }); + await writeAuditLog({ + tenantId: tx.tenantId, + actorId: tx.userId, + action: "update", + entity: "customer", + entityId: targetId, + before: target, + after: { merge: { role: "target", sourceId, moved } }, + }); + return { sourceId, targetId, moved }; }); - await writeAuditLog({ - tenantId: ctx.tenantId, - actorId: ctx.userId, - action: "update", - entity: "customer", - entityId: targetId, - before: target, - after: { merge: { role: "target", sourceId, moved } }, - }); - return { sourceId, targetId, moved }; } diff --git a/src/server/services/emergency/lookup.ts b/src/server/services/emergency/lookup.ts index 3970ad1..c2fbdfc 100644 --- a/src/server/services/emergency/lookup.ts +++ b/src/server/services/emergency/lookup.ts @@ -35,7 +35,7 @@ export async function searchCustomersForEmergency(ctx: ServiceCtx, rawQuery: str await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, - action: "export", + action: "read", entity: "emergency_customer_search", after: { query: q, resultCount: rows.length, customerIds: rows.map((r) => r.id) }, }); @@ -64,7 +64,7 @@ export async function listSitesForEmergency(ctx: ServiceCtx, customerId: string) await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, - action: "export", + action: "read", entity: "emergency_site_lookup", entityId: customerId, after: { resultCount: sites.length, siteIds: sites.map((s) => s.id) }, From b0aedb5d2303662552ca1ca68d6493dc12968c1b Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH 6/9] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20Lots?= =?UTF-8?q?e-Betrieb=20=E2=80=93=20Aufbewahrung=20KI-Protokoll=20und=20Tok?= =?UTF-8?q?en-Kontingent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aufräumpunkt k (Spec §31): - Aufbewahrung: services/lotse/retention.ts leert input/output und createdById von AiGeneration-Einträgen älter als AI_GENERATION_RETENTION_DAYS (Default 180), Metadaten bleiben, Audit je Mandant. Queue/Processor ai-retention, täglicher BullMQ-Job-Scheduler beim Start des craftvia-worker. - Kontingent: services/lotse/budget.ts (Tokens ein+aus je Kalendermonat, TenantSettings-Wert vor Env AI_MONTHLY_TOKEN_LIMIT, 0 = unbegrenzt). Lotse-Entwurf und Sprachnotiz-Zusammenfassung → blocked budget_exceeded mit Klartext; Import-Extraktion fällt auf manuelle Erfassung zurück (Hinweis ai_budget_exceeded). /settings/lotse: Kontingent setzen, Verbrauch anzeigen. - scripts/test-betrieb-audit.ts: Audit nach Commit/Rollback/verschachtelt, Merge atomar und in äußerer Transaktion, Audit „read", Aufbewahrung (Frist, Metadaten, Idempotenz, Mandant B), Kontingent (Mandant/Env/Vormonat/unbegrenzt, Rollen, Audit). Co-Authored-By: Claude Opus 5 --- messages/de/imports.json | 3 +- messages/de/lotse.json | 8 +- messages/en/imports.json | 3 +- messages/en/lotse.json | 8 +- scripts/craftvia-worker.ts | 7 +- scripts/test-betrieb-audit.ts | 209 +++++++++++++++++++++ src/app/(app)/settings/lotse/page.tsx | 35 +++- src/lib/imports/extraction.ts | 4 +- src/lib/lotse/action-state.ts | 1 + src/server/actions/lotse-settings.ts | 3 + src/server/jobs/processors/ai-retention.ts | 11 ++ src/server/jobs/processors/index.ts | 1 + src/server/jobs/queues.ts | 19 ++ src/server/services/imports/process.ts | 7 +- src/server/services/lotse/budget.ts | 57 ++++++ src/server/services/lotse/draft-report.ts | 2 + src/server/services/lotse/retention.ts | 50 +++++ src/server/services/lotse/settings.ts | 22 ++- src/server/services/lotse/voice.ts | 2 + 19 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 scripts/test-betrieb-audit.ts create mode 100644 src/server/jobs/processors/ai-retention.ts create mode 100644 src/server/services/lotse/budget.ts create mode 100644 src/server/services/lotse/retention.ts diff --git a/messages/de/imports.json b/messages/de/imports.json index b64c649..d4bef60 100644 --- a/messages/de/imports.json +++ b/messages/de/imports.json @@ -170,7 +170,8 @@ "email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.", "phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.", "end_before_start": "{field}: Ende liegt vor dem Beginn.", - "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen." + "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen.", + "ai_budget_exceeded": "Das monatliche KI-Kontingent ist aufgebraucht – deshalb keine automatische Erkennung." }, "positions": { "name": "Bezeichnung", diff --git a/messages/de/lotse.json b/messages/de/lotse.json index a879388..313ff19 100644 --- a/messages/de/lotse.json +++ b/messages/de/lotse.json @@ -76,7 +76,8 @@ "no_transcript": "Die Sprachnotiz hat noch keinen Text.", "pending": "Die Transkription läuft noch.", "conflict": "Inzwischen geändert. Bitte Seite neu laden.", - "invalid": "Bitte Eingaben prüfen." + "invalid": "Bitte Eingaben prüfen.", + "budget_exceeded": "Das monatliche KI-Kontingent des Betriebs ist aufgebraucht. Bitte den Bericht selbst schreiben oder das Büro fragen." }, "settings": { "back": "Einstellungen", @@ -96,6 +97,11 @@ "du": "du" }, "save": "Speichern", + "budget": "Monatliches KI-Kontingent (Tokens)", + "budgetHint": "Leer = Vorgabe der Plattform ({platform}). 0 = unbegrenzt. Ist das Kontingent aufgebraucht, bereitet der Lotse bis Monatsende nichts mehr vor.", + "budgetUnlimited": "unbegrenzt", + "budgetUsage": "Verbraucht seit {since}: {used} von {limit}", + "budgetExceeded": "Kontingent aufgebraucht", "dataTitle": "Welche Daten an wen gehen", "draftProvider": "Berichtsentwurf und Zusammenfassung", "transcriptionProvider": "Transkription von Sprachnotizen", diff --git a/messages/en/imports.json b/messages/en/imports.json index abf4b56..45b98f8 100644 --- a/messages/en/imports.json +++ b/messages/en/imports.json @@ -170,7 +170,8 @@ "email_invalid": "{field}: e-mail address format is invalid.", "phone_invalid": "{field}: phone number format is invalid.", "end_before_start": "{field}: end is before start.", - "manual_entry": "Automatic recognition is not available. Please enter manually." + "manual_entry": "Automatic recognition is not available. Please enter manually.", + "ai_budget_exceeded": "The monthly AI allowance is used up – therefore no automatic recognition." }, "positions": { "name": "Description", diff --git a/messages/en/lotse.json b/messages/en/lotse.json index 104753c..00b78a4 100644 --- a/messages/en/lotse.json +++ b/messages/en/lotse.json @@ -76,7 +76,8 @@ "no_transcript": "The voice note has no text yet.", "pending": "The transcription is still running.", "conflict": "Changed in the meantime. Please reload the page.", - "invalid": "Please check your input." + "invalid": "Please check your input.", + "budget_exceeded": "This business has used up its monthly AI allowance. Please write the report yourself or ask the office." }, "settings": { "back": "Settings", @@ -96,6 +97,11 @@ "du": "Informal (du)" }, "save": "Save", + "budget": "Monthly AI allowance (tokens)", + "budgetHint": "Empty = platform default ({platform}). 0 = unlimited. Once used up, Lotse prepares nothing until the end of the month.", + "budgetUnlimited": "unlimited", + "budgetUsage": "Used since {since}: {used} of {limit}", + "budgetExceeded": "Allowance used up", "dataTitle": "Which data goes where", "draftProvider": "Report draft and summary", "transcriptionProvider": "Voice note transcription", diff --git a/scripts/craftvia-worker.ts b/scripts/craftvia-worker.ts index f4395a0..649e67e 100644 --- a/scripts/craftvia-worker.ts +++ b/scripts/craftvia-worker.ts @@ -1,6 +1,6 @@ import "dotenv/config"; import { Worker } from "bullmq"; -import { JOB_QUEUES, workerConnection, closeJobQueues, type JobPayload } from "../src/server/jobs/queues"; +import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues"; import { PROCESSORS } from "../src/server/jobs/processors"; /** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */ @@ -24,6 +24,11 @@ async function main() { workers.push(w); console.info(`[worker] listening on ${name}`); } + // L10b: recurring jobs (AI log retention); a scheduling failure must not stop the queue workers + await scheduleRecurringJobs(connection).then( + () => console.info("[worker] recurring jobs scheduled"), + (err) => console.error("[worker] scheduling recurring jobs failed:", (err as Error).message), + ); const shutdown = async () => { await Promise.all(workers.map((w) => w.close())); await closeJobQueues(); diff --git a/scripts/test-betrieb-audit.ts b/scripts/test-betrieb-audit.ts new file mode 100644 index 0000000..bc1b029 --- /dev/null +++ b/scripts/test-betrieb-audit.ts @@ -0,0 +1,209 @@ +// Lane L10b „Betrieb & Aufräumen" — Transaktionen, Audit und Lotse-Betrieb: +// h) Audit-Einträge innerhalb von inTransaction erst nach dem Commit (Rollback → keine Einträge, +// außer „denied"; verschachtelt; aufgeschoben) +// d) mergeCustomers über inTransaction (atomar, in äußere Transaktion einbettbar) +// e) Audit-Aktion „read" für die Notdienst-Kundensuche +// k) KI-Protokoll: Aufbewahrungsfrist (Pseudonymisierung) + monatliches Token-Kontingent je Mandant +// Jeweils mit Mandantentrennung (B) und Rollen (Monteur → forbidden). +// +// Lauf: npx tsx scripts/test-betrieb-audit.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { writeAuditLog } from "../src/server/audit"; +import { inTransaction } from "../src/server/services/context"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup"; +import { aiGenerationRetentionDays, purgeExpiredAiGenerations } from "../src/server/services/lotse/retention"; +import { assertTokenBudget, getTokenBudget } from "../src/server/services/lotse/budget"; +import { getLotseSettings, updateLotseSettings } from "../src/server/services/lotse/settings"; +import { PROCESSORS } from "../src/server/jobs/processors"; +import { ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +const SLUG_A = "zz-l10b-audit-a"; +const SLUG_B = "zz-l10b-audit-b"; +const DOMAIN = "@zz-l10b-audit.test"; +const DAY = 24 * 60 * 60 * 1000; + +async function cleanup() { + for (const slug of [SLUG_A, SLUG_B]) { + const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } }); + if (!tenant) continue; + const where = { tenantId: tenant.id }; + await prisma.auditLog.deleteMany({ where }); + await prisma.aiGeneration.deleteMany({ where }); + await prisma.workOrder.deleteMany({ where }); + await prisma.site.deleteMany({ where }); + await prisma.contact.deleteMany({ where }); + await prisma.customer.deleteMany({ where }); + await prisma.tenantModule.deleteMany({ where }); + await prisma.tenantSettings.deleteMany({ where }); + await prisma.user.deleteMany({ where }); + await prisma.tenant.delete({ where: { id: tenant.id } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } }); +} + +async function user(tenantId: string, local: string) { + const email = `${local}${DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local } }); +} + +const auditCount = (tenantId: string, entity: string, entityId?: string, action?: string) => + prisma.auditLog.count({ where: { tenantId, entity, ...(entityId ? { entityId } : {}), ...(action ? { action } : {}) } }); + +async function main() { + await cleanup(); + const tenantA = await prisma.tenant.create({ data: { name: "L10b Audit A", slug: SLUG_A } }); + const tenantB = await prisma.tenant.create({ data: { name: "L10b Audit B", slug: SLUG_B } }); + const adminA = await user(tenantA.id, "admin-a"); + const techA = await user(tenantA.id, "tech-a"); + const adminB = await user(tenantB.id, "admin-b"); + const ctxAdminA = ctxFor(tenantA.id, adminA.id, "tenant-admin"); + const ctxTechA = ctxFor(tenantA.id, techA.id, "technician"); + const ctxAdminB = ctxFor(tenantB.id, adminB.id, "tenant-admin"); + await prisma.tenantSettings.create({ data: { tenantId: tenantA.id, orgName: "A" } }); + await prisma.tenantSettings.create({ data: { tenantId: tenantB.id, orgName: "B" } }); + + const customer = (tenantId: string, companyName: string) => prisma.customer.create({ data: { tenantId, companyName, city: "Kiel" } }); + const site = (tenantId: string, customerId: string) => + prisma.site.create({ data: { tenantId, customerId, name: "Halle", street: "Weg", houseNumber: "1", postalCode: "24103", city: "Kiel" } }); + + console.log("\n— h) Audit nach Commit —"); + const probe = await customer(tenantA.id, "Probe GmbH"); + await inTransaction(ctxAdminA, async (tx) => { + await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Lübeck" } }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "commit" }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 0, "innerhalb der Transaktion noch kein Audit-Eintrag (aufgeschoben)"); + }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 1, "nach Commit: Audit-Eintrag geschrieben"); + + await inTransaction(ctxAdminA, async (tx) => { + await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Flensburg" } }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "rollback" }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "denied", entity: "zz_l10b_tx", entityId: "rollback-denied" }); + throw new Error("zz rollback"); + }).catch(() => undefined); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: probe.id } })).city === "Lübeck", "Rollback: Fachänderung verworfen"); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback")) === 0, "Rollback: kein Audit-Eintrag für die verworfene Änderung"); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback-denied", "denied")) === 1, "Rollback: „denied\"-Eintrag bleibt (Sicherheitsereignis)"); + + await inTransaction(ctxAdminA, async (outer) => { + await inTransaction(outer, async (inner) => { + await writeAuditLog({ tenantId: inner.tenantId, action: "update", entity: "zz_l10b_tx", entityId: "nested" }); + }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: innere Transaktion schreibt nicht vorzeitig"); + throw new Error("zz outer rollback"); + }).catch(() => undefined); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: äußerer Rollback verwirft auch innere Audit-Einträge"); + await writeAuditLog({ tenantId: tenantA.id, action: "update", entity: "zz_l10b_tx", entityId: "direct" }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "direct")) === 1, "außerhalb einer Transaktion: sofort geschrieben"); + + console.log("\n— d) mergeCustomers atomar —"); + const src = await customer(tenantA.id, "Quelle GmbH"); + const tgt = await customer(tenantA.id, "Ziel GmbH"); + const srcSite = await site(tenantA.id, src.id); + await expectCode(() => mergeCustomers(ctxTechA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen"); + await expectCode(() => mergeCustomers(ctxAdminB, { sourceId: src.id, targetId: tgt.id, confirm: true }), "not_found", "Mandant B kann Kunden von A nicht zusammenführen"); + await inTransaction(ctxAdminA, async (tx) => { + await mergeCustomers(tx, { sourceId: src.id, targetId: tgt.id, confirm: true }); + throw new Error("zz merge rollback"); + }).catch(() => undefined); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: src.id } })).status !== "merged", "Merge in äußerer Transaktion + Rollback → Quelle nicht zusammengeführt"); + ok((await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === src.id, "… und Objekt nicht umgehängt"); + ok((await auditCount(tenantA.id, "customer", src.id)) === 0, "… und keine Merge-Audit-Einträge"); + const merged = await mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }); + ok(merged.moved.sites === 1 && (await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === tgt.id, "Merge: Objekt umgehängt"); + const srcAfter = await prisma.customer.findUniqueOrThrow({ where: { id: src.id } }); + ok(srcAfter.status === "merged" && srcAfter.mergedIntoId === tgt.id, "Merge: Quelle merged + mergedIntoId"); + ok((await auditCount(tenantA.id, "customer", src.id, "update")) === 1 && (await auditCount(tenantA.id, "customer", tgt.id, "update")) === 1, "Merge: Audit für Quelle und Ziel nach Commit"); + await expectCode(() => mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "conflict", "zweites Zusammenführen → conflict"); + + console.log("\n— e) Audit-Aktion „read\" —"); + const hits = await searchCustomersForEmergency(ctxTechA, "Ziel"); + ok(hits.some((h) => h.id === tgt.id), "Notdienst-Suche findet Kunden"); + const searchAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "emergency_customer_search" }, orderBy: { createdAt: "desc" } }); + ok(searchAudit?.action === "read" && searchAudit.actorId === techA.id, "Suchzugriff als Aktion „read\" protokolliert"); + ok((await searchCustomersForEmergency(ctxFor(tenantB.id, adminB.id, "technician"), "Ziel")).length === 0, "Mandant B findet keine Kunden von A"); + await expectCode(() => searchCustomersForEmergency(ctxFor(tenantA.id, adminA.id, "backoffice"), "Ziel"), "forbidden", "ohne emergency:create → forbidden"); + + console.log("\n— k) Aufbewahrung KI-Protokoll —"); + const now = new Date(); + const gen = (tenantId: string, createdAt: Date, tokens = 10, createdById: string | null = null) => + prisma.aiGeneration.create({ + data: { tenantId, kind: "report_draft", provider: "fake", model: "fake-1", input: { text: "Kunde ruft an" }, output: { workPerformed: "x" }, inputTokens: tokens, outputTokens: tokens, createdById, createdAt }, + }); + const oldA = await gen(tenantA.id, new Date(now.getTime() - 200 * DAY), 10, techA.id); + const newA = await gen(tenantA.id, new Date(now.getTime() - 10 * DAY), 10, techA.id); + const oldB = await gen(tenantB.id, new Date(now.getTime() - 200 * DAY), 10, adminB.id); + ok(aiGenerationRetentionDays() === 180, "Default-Aufbewahrung 180 Tage"); + process.env.AI_GENERATION_RETENTION_DAYS = "30"; + ok(aiGenerationRetentionDays() === 30, "AI_GENERATION_RETENTION_DAYS überschreibt den Default"); + delete process.env.AI_GENERATION_RETENTION_DAYS; + ok(typeof PROCESSORS["ai-retention"] === "function", "Job ai-retention im Worker registriert"); + + const r1 = await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] }); + const oldAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldA.id } }); + ok(r1.pseudonymised === 1 && oldAAfter.input === null && oldAAfter.output === null && oldAAfter.createdById === null, "abgelaufener Eintrag: Inhalte gelöscht, Personenbezug entfernt"); + ok(oldAAfter.inputTokens === 10 && oldAAfter.model === "fake-1" && oldAAfter.kind === "report_draft", "Metadaten (Tokens, Modell, Art) bleiben"); + const newAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: newA.id } }); + ok(newAAfter.input !== null && newAAfter.createdById === techA.id, "junger Eintrag unverändert"); + ok((await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldB.id } })).input !== null, "Lauf für Mandant A lässt Mandant B unberührt"); + ok((await auditCount(tenantA.id, "ai_generation_retention", undefined, "delete")) === 1, "Aufbewahrungslauf auditiert"); + ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] })).pseudonymised === 0, "zweiter Lauf idempotent"); + ok((await purgeExpiredAiGenerations({ now, days: 365, tenantIds: [tenantB.id] })).pseudonymised === 0, "längere Frist → nichts gelöscht"); + ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantB.id] })).pseudonymised === 1, "Mandant B eigener Lauf"); + + console.log("\n— k) Monatliches Token-Kontingent —"); + await prisma.aiGeneration.deleteMany({ where: { tenantId: { in: [tenantA.id, tenantB.id] } } }); + delete process.env.AI_MONTHLY_TOKEN_LIMIT; + await gen(tenantA.id, now, 600); + let budget = await getTokenBudget(ctxTechA, now); + ok(budget.limit === 0 && !budget.exceeded && budget.used === 1200, "ohne Limit: unbegrenzt, Verbrauch = Tokens ein+aus des Monats"); + await expectCode(() => updateLotseSettings(ctxTechA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }), "forbidden", "Monteur darf das Kontingent nicht setzen"); + await updateLotseSettings(ctxAdminA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }); + budget = await getTokenBudget(ctxTechA, now); + ok(budget.limit === 1000 && budget.source === "tenant" && budget.exceeded, "Mandanten-Limit 1000 bei 1200 Verbrauch → aufgebraucht"); + await expectCode(() => assertTokenBudget(ctxTechA, now), "blocked", "assertTokenBudget → blocked"); + try { + await assertTokenBudget(ctxTechA, now); + } catch (err) { + ok((err as { details?: { reason?: string } }).details?.reason === "budget_exceeded", "… mit reason budget_exceeded"); + } + const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } }); + ok((settingsAudit?.after as { monthlyTokenLimit?: number } | null)?.monthlyTokenLimit === 1000, "Limit-Änderung auditiert"); + ok((await getLotseSettings(ctxAdminA)).budget.tenantLimit === 1000, "Einstellungsseite liefert Limit und Verbrauch"); + ok(!(await getTokenBudget(ctxAdminB, now)).exceeded && (await getTokenBudget(ctxAdminB, now)).used === 0, "Mandant B: eigener Verbrauch, nicht betroffen"); + ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === null, "Mandant B: Limit unverändert"); + + await gen(tenantB.id, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - DAY), 5000); + process.env.AI_MONTHLY_TOKEN_LIMIT = "50"; + budget = await getTokenBudget(ctxAdminB, now); + ok(budget.limit === 50 && budget.source === "env" && budget.used === 0 && !budget.exceeded, "Env-Default greift; Verbrauch des Vormonats zählt nicht"); + await gen(tenantB.id, now, 30); + ok((await getTokenBudget(ctxAdminB, now)).exceeded, "Env-Limit 50 bei 60 Tokens → aufgebraucht"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 0 }); + ok(!(await getTokenBudget(ctxAdminB, now)).exceeded, "Mandanten-Limit 0 = unbegrenzt überschreibt Env"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral" }); + ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === 0, "Speichern ohne Limit-Feld lässt das Limit unverändert"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: null }); + ok((await getTokenBudget(ctxAdminB, now)).source === "env", "null → wieder Plattform-Vorgabe"); + delete process.env.AI_MONTHLY_TOKEN_LIMIT; + + // guard: tenant db cannot read the other tenant's usage + const crossUsage = await dbForTenant(tenantB.id).aiGeneration.count({ where: { tenantId: tenantA.id } }).catch(() => 0); + ok(crossUsage === 0, "Mandanten-Client von B sieht keine KI-Nutzung von A"); +} + +main() + .catch((err) => { + console.error(err); + ok(false, `unerwarteter Fehler: ${(err as Error).message}`); + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup failed", e)); + await prisma.$disconnect(); + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Audit-/Transaktions-/Lotse-Betriebsprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx index 19f5d59..449a264 100644 --- a/src/app/(app)/settings/lotse/page.tsx +++ b/src/app/(app)/settings/lotse/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; +import { getLocale, getTranslations } from "next-intl/server"; import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react"; import { LotseMark } from "@/components/lotse/lotse-mark"; import { PageHead } from "@/components/mockup-ui"; @@ -27,8 +27,9 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { const ctx = await readCtx(); if (!can(ctx, "tenant:manage")) redirect("/dashboard"); - const [sp, s, t] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse")]); + const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]); const statusLabels = { ok: t("settings.configured"), off: t("settings.notConfigured") }; + const nf = new Intl.NumberFormat(locale); return (
@@ -72,6 +73,36 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams ))} +
+ +

+ {t("settings.budgetHint", { platform: s.budget.platformLimit > 0 ? nf.format(s.budget.platformLimit) : t("settings.budgetUnlimited") })} +

+ +

+ {t("settings.budgetUsage", { + since: new Date(s.budget.periodStart).toLocaleDateString(locale, { timeZone: "UTC" }), + used: nf.format(s.budget.used), + limit: s.budget.limit > 0 ? nf.format(s.budget.limit) : t("settings.budgetUnlimited"), + })} + {s.budget.exceeded && ( + + {t("settings.budgetExceeded")} + + )} +

+
diff --git a/src/lib/imports/extraction.ts b/src/lib/imports/extraction.ts index bc0f58e..ca69d19 100644 --- a/src/lib/imports/extraction.ts +++ b/src/lib/imports/extraction.ts @@ -199,7 +199,9 @@ export type PlausibilityHintCode = | "email_invalid" | "phone_invalid" | "end_before_start" - | "manual_entry"; + | "manual_entry" + /** L10b: monthly AI token budget of the tenant used up → manual entry */ + | "ai_budget_exceeded"; export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode }; diff --git a/src/lib/lotse/action-state.ts b/src/lib/lotse/action-state.ts index f619283..081b109 100644 --- a/src/lib/lotse/action-state.ts +++ b/src/lib/lotse/action-state.ts @@ -12,6 +12,7 @@ export const LOTSE_ERROR_CODES = [ "pending", "conflict", "invalid", + "budget_exceeded", ] as const; export type LotseActionErrorCode = (typeof LOTSE_ERROR_CODES)[number]; diff --git a/src/server/actions/lotse-settings.ts b/src/server/actions/lotse-settings.ts index 98bb19f..896f8e1 100644 --- a/src/server/actions/lotse-settings.ts +++ b/src/server/actions/lotse-settings.ts @@ -19,9 +19,12 @@ export async function saveLotseSettings(fd: FormData): Promise { try { requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB const ctx = await requireApiContext(null, "tenant:manage"); + // L10b: empty = platform default (null); invalid numbers are rejected by the service schema + const rawLimit = String(fd.get("monthlyTokenLimit") ?? "").trim(); await updateLotseSettings(ctx, { enabled: fd.get("enabled") === "on", addressForm: (["sie", "du"].includes(String(fd.get("addressForm"))) ? String(fd.get("addressForm")) : "neutral") as "sie" | "du" | "neutral", + monthlyTokenLimit: rawLimit === "" ? null : Number(rawLimit), }); revalidatePath("/settings/lotse"); revalidatePath("/", "layout"); diff --git a/src/server/jobs/processors/ai-retention.ts b/src/server/jobs/processors/ai-retention.ts new file mode 100644 index 0000000..8be5632 --- /dev/null +++ b/src/server/jobs/processors/ai-retention.ts @@ -0,0 +1,11 @@ +import { purgeExpiredAiGenerations } from "@/server/services/lotse/retention"; + +/** + * Daily retention job for the AI log (L10b, Spec §31). Scheduled by the craftvia worker + * (`scheduleRecurringJobs`); the payload carries no tenant — the service iterates all tenants and + * writes through dbForTenant. + */ +export async function process(): Promise { + const res = await purgeExpiredAiGenerations(); + console.info(`[ai-retention] ${res.pseudonymised} entries older than ${res.days} days pseudonymised (${res.tenants} tenants)`); +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index 4ac9ea9..bc7653b 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -12,6 +12,7 @@ export const PROCESSORS: Partial Promise import("./transcription").then((m) => m.process), "report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle "image-derivatives": () => import("./image-derivatives").then((m) => m.process), + "ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker) }; /** Inline fallback when no Redis is available (dev/demo). */ diff --git a/src/server/jobs/queues.ts b/src/server/jobs/queues.ts index 315d0b2..f310555 100644 --- a/src/server/jobs/queues.ts +++ b/src/server/jobs/queues.ts @@ -12,6 +12,7 @@ export const JOB_QUEUES = { transcription: "transcription", reportPdf: "report-pdf", imageDerivatives: "image-derivatives", + aiRetention: "ai-retention", } as const; export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES]; @@ -80,6 +81,24 @@ export async function enqueueJob(name: JobQueueName, payload: JobPayload): Promi return true; } +/** + * Recurring jobs (L10b), registered once by the craftvia worker at start. BullMQ job schedulers + * are idempotent per id, so several worker replicas do not create duplicates. + * - ai-retention: daily pseudonymisation of AI log contents (services/lotse/retention.ts) + */ +export async function scheduleRecurringJobs(connection: Redis): Promise { + const q = new Queue(JOB_QUEUES.aiRetention, { connection }); + try { + await q.upsertJobScheduler( + "ai-retention-daily", + { every: 24 * 60 * 60 * 1000 }, + { name: JOB_QUEUES.aiRetention, data: { tenantId: "*", entityId: "retention" }, opts: { removeOnComplete: { count: 30 }, removeOnFail: { count: 30 } } }, + ); + } finally { + await q.close(); + } +} + export async function closeJobQueues(): Promise { await Promise.all([...queues.values()].map((q) => q.close())); queues.clear(); diff --git a/src/server/services/imports/process.ts b/src/server/services/imports/process.ts index 0a17f59..bee48f0 100644 --- a/src/server/services/imports/process.ts +++ b/src/server/services/imports/process.ts @@ -13,6 +13,7 @@ import { checkPlausibility } from "@/lib/imports/plausibility"; // TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface). import { findDuplicateCustomers } from "@/server/services/customers/duplicates"; import { findSiteCandidates } from "./site-candidates"; +import { getTokenBudget } from "@/server/services/lotse/budget"; export type ProcessDeps = { provider: DocumentExtractionProvider | null; @@ -45,9 +46,11 @@ export async function processImport(ctx: ServiceCtx, importId: string, deps: Pro let providerName: string | null = null; let model: string | null = null; - if (!deps.provider) { + // L10b: monthly AI token budget used up → same graceful path as without provider + const budgetExceeded = deps.provider ? (await getTokenBudget(ctx, deps.now)).exceeded : false; + if (!deps.provider || budgetExceeded) { fields = emptyExtraction(); - hints = [{ field: null, code: "manual_entry" }]; + hints = budgetExceeded ? [{ field: null, code: "manual_entry" }, { field: null, code: "ai_budget_exceeded" }] : [{ field: null, code: "manual_entry" }]; } else { const bytes = await deps.loadBytes({ storageKey: doc.storageKey }); if (!bytes) throw new Error("file_unavailable"); diff --git a/src/server/services/lotse/budget.ts b/src/server/services/lotse/budget.ts new file mode 100644 index 0000000..d2f1d79 --- /dev/null +++ b/src/server/services/lotse/budget.ts @@ -0,0 +1,57 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; + +/** + * Monthly AI token budget per tenant (Spec §31 „Kostenlimit", L10b). + * Limit = `TenantSettings.aiMonthlyTokenLimit` if set, otherwise env `AI_MONTHLY_TOKEN_LIMIT` + * (default 0). 0 = unlimited. Usage = sum of input + output tokens of all `AiGeneration`s of the + * tenant since the start of the current calendar month (UTC). Checked BEFORE a provider call — + * one call may overshoot the limit by its own size, the next one is refused. + * Transcription (audio) reports no tokens and is not counted. + */ + +export function envMonthlyTokenLimit(): number { + const v = Number(process.env.AI_MONTHLY_TOKEN_LIMIT); + return Number.isInteger(v) && v >= 0 ? v : 0; +} + +export type TokenBudget = { + /** effective limit, 0 = unlimited */ + limit: number; + source: "tenant" | "env"; + /** tenant override as stored (null = platform default) */ + tenantLimit: number | null; + used: number; + periodStart: string; + exceeded: boolean; +}; + +export function monthStart(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +} + +export async function getTokenBudget(ctx: Pick, now: Date = new Date()): Promise { + const start = monthStart(now); + const [settings, usage] = await Promise.all([ + ctx.db.tenantSettings.findFirst({ select: { aiMonthlyTokenLimit: true } }), + ctx.db.aiGeneration.aggregate({ where: { createdAt: { gte: start } }, _sum: { inputTokens: true, outputTokens: true } }), + ]); + const tenantLimit = settings?.aiMonthlyTokenLimit ?? null; + const limit = tenantLimit ?? envMonthlyTokenLimit(); + const used = (usage._sum.inputTokens ?? 0) + (usage._sum.outputTokens ?? 0); + return { + limit, + source: tenantLimit === null ? "env" : "tenant", + tenantLimit, + used, + periodStart: start.toISOString(), + exceeded: limit > 0 && used >= limit, + }; +} + +/** Throws `blocked` (details.reason = "budget_exceeded") when the monthly budget is used up. */ +export async function assertTokenBudget(ctx: Pick, now: Date = new Date()): Promise { + const budget = await getTokenBudget(ctx, now); + if (budget.exceeded) { + throw new ServiceError("blocked", "monthly AI token budget exceeded", { reason: "budget_exceeded", limit: budget.limit, used: budget.used }); + } +} diff --git a/src/server/services/lotse/draft-report.ts b/src/server/services/lotse/draft-report.ts index 7c17042..3671850 100644 --- a/src/server/services/lotse/draft-report.ts +++ b/src/server/services/lotse/draft-report.ts @@ -10,6 +10,7 @@ import { contentOf, requireVisibleReport } from "@/server/services/reports/commo import { buildReportDraftInput } from "./minimize"; import { assertLotseEnabled, lotseVoice } from "./settings"; import { loadDraftNotes, loadMinimizationContext } from "./sources"; +import { assertTokenBudget } from "./budget"; export type LotseDeps = { provider: LotseAssistant | null; now?: () => Date }; export const defaultLotseDeps = (): LotseDeps => ({ provider: getLotseProvider() }); @@ -45,6 +46,7 @@ export async function prepareDraftInput(ctx: ServiceCtx, report: Report, content export async function draftReportWithLotse(ctx: ServiceCtx, reportId: string, deps: LotseDeps = defaultLotseDeps()) { const report = await requireDraftableReport(ctx, reportId); if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" }); + await assertTokenBudget(ctx); // L10b: monthly token budget (blocked, reason budget_exceeded) const content = contentOf(report); const input = await prepareDraftInput(ctx, report, content); diff --git a/src/server/services/lotse/retention.ts b/src/server/services/lotse/retention.ts new file mode 100644 index 0000000..80fc599 --- /dev/null +++ b/src/server/services/lotse/retention.ts @@ -0,0 +1,50 @@ +import { Prisma } from "@prisma/client"; +import { writeAuditLog } from "@/server/audit"; +import { dbForTenant, prisma } from "@/server/db"; + +/** + * Retention of the AI log (Spec §31 „Aufbewahrung", L9 offener Punkt 4, L10b): + * after `AI_GENERATION_RETENTION_DAYS` (default 180) the CONTENT of an AiGeneration (minimised + * input sent to the provider, provider output) is deleted and the person reference is removed + * (`createdById = null`). Metadata (kind, provider, model, tokens, entity reference, time) stays + * for cost statistics and the reference from reports (`aiGenerationId`) keeps resolving. + * Runs daily in the craftvia worker (queue `ai-retention`); idempotent. + */ + +export const DEFAULT_AI_GENERATION_RETENTION_DAYS = 180; + +export function aiGenerationRetentionDays(): number { + const v = Number(process.env.AI_GENERATION_RETENTION_DAYS); + return Number.isInteger(v) && v > 0 ? v : DEFAULT_AI_GENERATION_RETENTION_DAYS; +} + +export type RetentionResult = { cutoff: string; days: number; tenants: number; pseudonymised: number }; + +export async function purgeExpiredAiGenerations(opts: { now?: Date; days?: number; tenantIds?: string[] } = {}): Promise { + const days = opts.days ?? aiGenerationRetentionDays(); + const cutoff = new Date((opts.now ?? new Date()).getTime() - days * 24 * 60 * 60 * 1000); + // tenant list = platform data (no tenant content); every content change runs through dbForTenant + const tenantIds = opts.tenantIds ?? (await prisma.tenant.findMany({ select: { id: true } })).map((t) => t.id); + + let pseudonymised = 0; + for (const tenantId of tenantIds) { + const db = dbForTenant(tenantId); + const res = await db.aiGeneration.updateMany({ + where: { + createdAt: { lt: cutoff }, + OR: [{ input: { not: Prisma.DbNull } }, { output: { not: Prisma.DbNull } }, { createdById: { not: null } }], + }, + data: { input: Prisma.DbNull, output: Prisma.DbNull, createdById: null }, + }); + if (res.count > 0) { + pseudonymised += res.count; + await writeAuditLog({ + tenantId, + action: "delete", + entity: "ai_generation_retention", + after: { count: res.count, cutoff: cutoff.toISOString(), retentionDays: days }, + }); + } + } + return { cutoff: cutoff.toISOString(), days, tenants: tenantIds.length, pseudonymised }; +} diff --git a/src/server/services/lotse/settings.ts b/src/server/services/lotse/settings.ts index 3526e9c..8320ecd 100644 --- a/src/server/services/lotse/settings.ts +++ b/src/server/services/lotse/settings.ts @@ -5,6 +5,7 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client"; import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; import { writeAuditLog } from "@/server/audit"; import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { envMonthlyTokenLimit, getTokenBudget } from "./budget"; /** * Lotse settings per tenant (lane L9): on/off = module toggle `lotse` (TenantModule, missing row = on), @@ -38,19 +39,26 @@ export async function lotseVoice(ctx: Pick): Promise<{ address export async function getLotseSettings(ctx: ServiceCtx) { assertCan(ctx, "tenant:manage"); - const [enabled, s] = await Promise.all([isLotseEnabled(ctx), ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } })]); + const [enabled, s, budget] = await Promise.all([ + isLotseEnabled(ctx), + ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }), + getTokenBudget(ctx), + ]); const transcription = transcriptionConfig(); return { enabled, addressForm: toAddressForm(s?.lotseAddressForm), draft: { configured: isAiConfigured(), provider: "Anthropic (Claude)", model: AI_MODEL }, transcription, + budget: { ...budget, platformLimit: envMonthlyTokenLimit() }, }; } export const lotseSettingsSchema = z.object({ enabled: z.boolean(), addressForm: z.enum(["sie", "du", "neutral"]), + /** L10b: tenant token budget per month; null = platform default, 0 = unlimited, undefined = unchanged */ + monthlyTokenLimit: z.number().int().min(0).max(1_000_000_000).nullable().optional(), }); export type LotseSettingsInput = z.input; @@ -59,10 +67,13 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp const input = lotseSettingsSchema.parse(raw); const addressForm = input.addressForm === "neutral" ? null : input.addressForm; + const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true } }); const before = { enabled: await isLotseEnabled(ctx), - addressForm: (await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true } }))?.lotseAddressForm ?? null, + addressForm: stored?.lotseAddressForm ?? null, + monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null, }; + const monthlyTokenLimit = input.monthlyTokenLimit === undefined ? before.monthlyTokenLimit : input.monthlyTokenLimit; await inTransaction(ctx, async (tx) => { await tx.db.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY } }, @@ -70,14 +81,15 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp create: { tenantId: tx.tenantId, moduleKey: LOTSE_MODULE_KEY, enabled: input.enabled }, }); const existing = await tx.db.tenantSettings.findFirst({ select: { id: true } }); + const data = { lotseAddressForm: addressForm, aiMonthlyTokenLimit: monthlyTokenLimit }; if (existing) { - await tx.db.tenantSettings.update({ where: { id: existing.id }, data: { lotseAddressForm: addressForm } }); + await tx.db.tenantSettings.update({ where: { id: existing.id }, data }); } else { const tenant = await tx.db.tenant.findUnique({ where: { id: tx.tenantId }, select: { name: true } }); - await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", lotseAddressForm: addressForm } }); + await tx.db.tenantSettings.create({ data: { tenantId: tx.tenantId, orgName: tenant?.name ?? "—", ...data } }); } }); - const after = { enabled: input.enabled, addressForm }; + const after = { enabled: input.enabled, addressForm, monthlyTokenLimit }; await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "lotse_settings", entityId: ctx.tenantId, before, after }); return after; } diff --git a/src/server/services/lotse/voice.ts b/src/server/services/lotse/voice.ts index b66cd48..a39577b 100644 --- a/src/server/services/lotse/voice.ts +++ b/src/server/services/lotse/voice.ts @@ -7,6 +7,7 @@ import { createNote } from "@/server/services/field/notes"; import { workOrderScope } from "@/server/services/work-orders/visibility"; import { defaultLotseDeps, type LotseDeps } from "./draft-report"; import { scrubText } from "./minimize"; +import { assertTokenBudget } from "./budget"; import { assertLotseEnabled, lotseVoice } from "./settings"; import { loadMinimizationContext } from "./sources"; import { TRANSCRIPT_SEPARATOR } from "./transcription"; @@ -67,6 +68,7 @@ export async function summarizeVoiceNote(ctx: ServiceCtx, voiceNoteId: string, d await assertLotseEnabled(ctx); if (!voice.transcript?.trim()) throw new ServiceError("invalid", "voice note has no transcript", { reason: "no_transcript" }); if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" }); + await assertTokenBudget(ctx); // L10b: monthly token budget const [minimization, lang] = await Promise.all([loadMinimizationContext(ctx, voice.workOrderId), lotseVoice(ctx)]); const transcript = scrubText(voice.transcript, minimization); From 21d6dc016a127336e10773e5c838e3ca82cb180d Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH 7/9] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20eink?= =?UTF-8?q?lappbare=20Backoffice-Sidebar,=20Berichtseditor=20mit=20Offline?= =?UTF-8?q?-Entwurf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aufräumpunkt g (L1 offener Punkt 10): components/backoffice-frame.tsx – unter 1024 px ist die Sidebar ein Drawer hinter einem Menü-Button (44 px, aria-expanded, schließt bei Navigation, Hintergrund, Escape); ab 1024 px statisch wie bisher. Header kompakter auf schmalen Screens. - Aufräumpunkt i (L7 offener Punkt 7): mobiler Berichtseditor speichert ungesicherte Eingaben über useOfflineDraft (IndexedDB je Mandant/Nutzer). Ein Entwurf wird nur wiederhergestellt, solange die Servertexte unverändert sind (sonst gewinnt der Server, z. B. nach Übernahme eines Lotse-Vorschlags); nach Speichern/Absenden gelöscht. Co-Authored-By: Claude Opus 5 --- messages/de/nav.json | 4 +- messages/en/nav.json | 4 +- src/app/(app)/layout.tsx | 27 ++++--- src/components/backoffice-frame.tsx | 77 +++++++++++++++++++ .../reports/mobile/report-editor.tsx | 40 ++++++++-- 5 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 src/components/backoffice-frame.tsx diff --git a/messages/de/nav.json b/messages/de/nav.json index 72a2338..efed3cc 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -14,5 +14,7 @@ "audit": "Audit-Protokoll", "email": "E-Mail-Versand", "lotse": "Lotse (KI)", - "admin": "Admin-Konsole" + "admin": "Admin-Konsole", + "openMenu": "Menü öffnen", + "closeMenu": "Menü schließen" } diff --git a/messages/en/nav.json b/messages/en/nav.json index 46a6776..91f4ba6 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -14,5 +14,7 @@ "audit": "Audit log", "email": "E-mail delivery", "lotse": "Lotse (AI)", - "admin": "Admin console" + "admin": "Admin console", + "openMenu": "Open menu", + "closeMenu": "Close menu" } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index a30979f..8f77b5e 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -13,6 +13,7 @@ import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { NotificationBell } from "@/components/notifications/bell"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; +import { BackofficeFrame } from "@/components/backoffice-frame"; export default async function AppLayout({ children, @@ -56,9 +57,8 @@ export default async function AppLayout({ await signOut({ redirectTo: "/login" }); } - return ( -
- + + ); -
-
-
+ // L10b: collapsible sidebar below 1024 px (src/components/backoffice-frame.tsx) + return ( + +
-
+

{session.user.name}

{session.user.tenantSlug}

@@ -105,9 +108,9 @@ export default async function AppLayout({ {tc("logout")} -
- {children} -
-
+ + }> + {children} + ); } diff --git a/src/components/backoffice-frame.tsx b/src/components/backoffice-frame.tsx new file mode 100644 index 0000000..d3473fd --- /dev/null +++ b/src/components/backoffice-frame.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { Menu, X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Backoffice shell (L10b, L1 offener Punkt 10): below 1024 px the sidebar is collapsed behind a + * menu button and opens as an overlay drawer, so backoffice pages are usable on tablets and + * phones. From 1024 px the sidebar is static as before. The drawer closes on navigation (link + * click), on the backdrop, the close button and Escape. Closed drawers are `invisible` below + * 1024 px, so their links leave the tab order. + */ +export function BackofficeFrame({ + sidebar, + header, + children, + labels, +}: { + sidebar: React.ReactNode; + header: React.ReactNode; + children: React.ReactNode; + labels: { open: string; close: string }; +}) { + const [open, setOpen] = useState(false); + + const closeOnLink = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("a")) setOpen(false); + }; + const closeOnEscape = (e: React.KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + + return ( +
+ {open &&
setOpen(false)} />} + + +
+
+ + {header} +
+ {children} +
+
+ ); +} diff --git a/src/components/reports/mobile/report-editor.tsx b/src/components/reports/mobile/report-editor.tsx index 9457e9c..d14f015 100644 --- a/src/components/reports/mobile/report-editor.tsx +++ b/src/components/reports/mobile/report-editor.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useActionState, useEffect, useState } from "react"; import { useTranslations } from "next-intl"; -import { ArrowRight, Save, Send } from "lucide-react"; +import { ArrowRight, History, Save, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -12,30 +12,59 @@ import { REPORT_REQUIRED_TEXTS, REPORT_TEXT_FIELDS, TEXT_MAX, type ReportTexts, import { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow"; import { ActionMessage } from "../action-message"; import { LotseReviewConfirm } from "@/components/lotse/review-confirm"; +import { useOfflineDraft } from "@/components/offline/hooks"; + +/** Local draft (IndexedDB, L7) + the server texts it was based on. */ +type ReportDraft = { base: ReportTexts; texts: ReportTexts }; + +const sameTexts = (a: ReportTexts | undefined, b: ReportTexts) => !!a && REPORT_TEXT_FIELDS.every((f) => (a[f] ?? "") === (b[f] ?? "")); /** * Mobile report editor: technician checks/extends the prefilled texts. * Daily report: save or submit directly (signature optional). Completion: save and continue to signature. + * + * L10b (L7 offener Punkt 7): unsaved input is kept as an offline draft (`useOfflineDraft`, per + * tenant + user, removed on logout). A draft is only restored while the server texts are still the + * ones it was based on — if the report changed meanwhile (saved on another device, Lotse suggestion + * taken over) the server version wins and the stale draft is discarded. */ export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = false }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string; aiDrafted?: boolean }) { const t = useTranslations("reports"); + const tOffline = useTranslations("offline"); const router = useRouter(); const [intent, setIntent] = useState<"save" | "sign">("save"); const [saveState, save, saving] = useActionState(saveReportTextsAction, IDLE); const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE); const required = new Set(REPORT_REQUIRED_TEXTS[type]); + const [draft, setDraft, clearDraft, restored] = useOfflineDraft(`report:${reportId}`, { base: texts, texts }); + const stale = !sameTexts(draft.base, texts); + const values = stale ? texts : draft.texts; + useEffect(() => { + if (restored && stale) void clearDraft(); + }, [restored, stale, clearDraft]); + useEffect(() => { + if (saveState.status === "ok") void clearDraft(); if (saveState.status === "ok" && intent === "sign" && signHref) router.push(signHref); - }, [saveState, intent, router, signHref]); + }, [saveState, intent, router, signHref, clearDraft]); useEffect(() => { - if (submitState.status === "ok") router.refresh(); - }, [submitState, router]); + if (submitState.status === "ok") { + void clearDraft(); + router.refresh(); + } + }, [submitState, router, clearDraft]); return (

{t("mobile.edit")}

+ {restored && !stale && ( +

+ + {tOffline("view.draftRestored")} +

+ )} {REPORT_TEXT_FIELDS.map((f) => (