From 49f05c8db3639c17add4ab0263f2fefbea68c0a0 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20OpenAPI?= =?UTF-8?q?=203.1=20unter=20/api/v1/openapi.json,=20API-Doku=20und=20API-T?= =?UTF-8?q?est?= 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}`));