- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt, Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status - /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung), Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot, Enumeration-Schutz, Slug-Kollisionen - Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern, umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit) - Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi), Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil - Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand - Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding - Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.5 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 | /planning/board |
work_orders | Service: work_order:read_all oder Teamleiter (eigene Teams) |
Plantafel: Aufträge je Team/Tag, Kapazität, Auslastung, Konflikte (from, to ≤ 42 Tage, teamId, orderTypeId, priority) |
| POST | /planning/schedule |
work_orders | work_order:assign + Service: work_order:write |
Einplanen (Team + Termin + Dauer, atomar, baseVersion Pflicht, 409 „Auftrag wurde zwischenzeitlich geändert“) |
| GET | /planning/recommendations |
work_orders | work_order:assign |
Einsatz-Empfehlungen (Luftlinie + freie Kapazität, Top 5) + nahe ungeplante Aufträge; nur Vorschläge |
| GET | /planning/live |
work_orders | Service: work_order:read_all oder Teamleiter |
Live-Lage: Status aus aktiver WorkSession, Standort = Objekt des Auftrags, keine Geräte-Koordinaten |
| GET | /openapi.json |
– | angemeldet | OpenAPI-3.1-Dokument |
Testphase: Nur-Lesen nach Ablauf (L15)
Für abgelaufene Testmandanten beantworten alle nicht lesenden Anfragen (POST/PUT/PATCH/DELETE, inkl.
/sync und /uploads) mit 422 { "error": { "code": "blocked", "message": "trial_expired", "details": { "readOnly": true, "message": "…", "deletionDueAt": "…" } } }.
GET-Anfragen, Datei-Downloads und PDFs bleiben erlaubt. Siehe TESTPHASE.md.