L10b Betrieb & Aufräumen: OpenAPI 3.1 unter /api/v1/openapi.json, API-Doku und API-Test
- 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>
This commit is contained in:
@@ -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=<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 |
|
||||
Reference in New Issue
Block a user