- 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 <noreply@anthropic.com>
8.3 KiB
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) mit401. - 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, nicht403. - CSRF: Schreibende Methoden (POST/PATCH) nur Same-Origin: Der
Origin-Header muss zum Host passen,Sec-Fetch-Sitemusssame-originodernonesein. Sonst403 forbidden.
Fehlerformat
Alle Routen antworten im Fehlerfall mit Cache-Control: no-store und
{ "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 /syncnimmt{ deviceId, operations[] }mit 1–100 Operationen an (die PWA-Outbox schickt Batches ≤ 50). Jede Operation hat eineclientOpId(UUID) und wird einzeln angewendet. Die HTTP-Antwort ist200, das Ergebnis steht je Operation inresults[]:applied|duplicate|conflict|rejected(miterrorCode,message,idMap,entityVersion).- Idempotenz: Eine wiederholte
clientOpId(je Mandant) liefertduplicatemit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operationrejected. - Konflikte:
work_order.transitionundreport.submitverlangenbaseVersion. Weicht sie vonWorkOrder.versionab, lautet das Ergebnisconflict,entityVersionist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B.clientId, werden überidMapauf Server-IDs abgebildet). - Den opType-Katalog mit den Payload-Schemas enthält
src/lib/sync/ops.ts(Spec: KomponentenSyncPayload*). - REST-Schreibrouten für Aufträge (
PATCH /work-orders/{id},/assign,/transition) akzeptieren optionalbaseVersionund antworten bei Abweichung mit409.
Uploads
POST /uploads(Einsatz): multipart mitfile,clientId(UUID),workOrderId,kind(photo|voice_note) und optionalpreview(Thumbnail ≤ 2 MB). Maximal 25 MB, der Inhalt wird per Magic Bytes geprüft. Idempotent überclientId: dieselbe clientId liefert200 { documentId, duplicate: true }, ein neuer Upload201 { documentId, duplicate: false }. DiedocumentIdwird danach inphoto.attach/voice.attachreferenziert.POST /work-orders/{id}/documents: multipart mitfile,category,visibility,title?. Antwort201. MitAccept: text/htmlkommt stattdessen ein303-Redirect (Backoffice-Formular).POST /work-orders/import: multipart mitfile(PDF/JPEG/PNG, ≤ 25 MB), Antwort201 { 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=<ISO>, 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 |