API Contracts — Backend
Source of truth: the live OpenAPI document at http://localhost:3010/api/docs/openapi.json (Swagger UI at /api/docs/). Every public handler is annotated with [utoipa::path] in backend/src/**/routes*.rs, and every DTO with [derive(ToSchema)]. The compile-time guarantee in backend/src/openapi.rs::tests::openapi_doc_includes_expected_path_count keeps the catalog from silently shrinking.
This file is a navigable index of the same surface — keep it in sync when adding a route by also re-running the dev backend and watching for the new path in /api/docs/.
Conventions
-
Base path: all authenticated endpoints under
/api/v1. Health probe at/healthis unauthenticated. -
Auth: Bearer JWT (Zitadel-issued), validated by
auth_middleware(backend/src/auth/middleware.rs). The middleware injects aTenantContextrequest extension; every handler that touches the DB extracts it. -
Tenant scope: every authenticated handler runs its DB work via
TenantContext::rls_transaction(&pool), which setsapp.tenant_idGUC for that transaction. RLS policies (<table>_isolation) do the rest. -
Path-param syntax: Axum 0.8
{id}(not:id). -
Error shape:
shared::error::ApiError→ JSON{ "error": "<message>" }plus an HTTP status. 401 = no/bad token; 403 = wrong role; 404 = not found / cross-tenant; 422 = validation; 5xx = server.
Endpoint catalog by tag
tenants
-
GET /api/v1/tenant/settings→TenantSettings -
PATCH /api/v1/tenant/settings→TenantSettings(full-replace semantics today; PATCH-vs-PUT is a P2 follow-up)
roles
-
GET /api/v1/roles→Vec<RoleDefinition> -
POST /api/v1/rolesbodyCreateRoleRequest→RoleDefinition -
DELETE /api/v1/roles/{id}→ 204
locations
-
GET /api/v1/locations→Vec<Location> -
POST /api/v1/locationsbodyCreateLocationRequest→Location(server-side Nominatim geocoding fillslat/lngifstreet+cityare present) -
DELETE /api/v1/locations/{id}→ 204
users
-
GET /api/v1/users→Vec<UserRecord>(admin only — list employees in tenant) -
GET /api/v1/users/{id}→UserDetail(user + qualifications) -
POST /api/v1/users/{id}/qualifications/{role_id}→ 204 (admin grants role) -
DELETE /api/v1/users/{id}/qualifications/{role_id}→ 204 (admin revokes)
events
-
GET /api/v1/events→Vec<Event> -
POST /api/v1/eventsbodyCreateEventRequest→Event -
GET /api/v1/events/{id}→Event -
POST /api/v1/events/{id}/publish→Event(transitionsdraft→published)
shifts
-
GET /api/v1/shifts(query:?status=draft|open|filled|completed,?event_id=...) →Vec<Shift> -
POST /api/v1/shiftsbodyCreateShiftRequest→Shift -
GET /api/v1/shifts/{id}→Shift -
GET /api/v1/shifts/{id}/slots→Vec<ShiftSlotWithFill>(slots + computedfilled_count; the same RLS transaction guarantees count and slot list can’t drift) -
POST /api/v1/shifts/{id}/publish→Shift -
POST /api/v1/shifts/{id}/split/{n}→Vec<Shift>(splits one shift into N sub-shifts) -
GET /api/v1/events/{event_id}/shifts→Vec<Shift>(event-scoped list) -
POST /api/v1/events/{event_id}/shifts/bulkbodyBulkGenerateRequest→Vec<Shift>(generates one shift per day in the event window usingstart_time/end_timein tenant timezone)
assignments
-
POST /api/v1/shifts/{shift_id}/slots/{slot_id}/claim→ShiftAssignment(employee self-claim; capacity-checked but currently susceptible to a P1 race — seeclaim_slotfollow-up) -
POST /api/v1/shifts/{shift_id}/slots/{slot_id}/assignbodyAssignRequest { user_id }→ShiftAssignment(admin assigns)
time-tracking
-
GET /api/v1/time-tracking/balance→OvertimeBalance(caller’s own) -
GET /api/v1/time-tracking/balances→Vec<OvertimeBalance>(admin only) -
GET /api/v1/time-tracking/balance/{user_id}→OvertimeBalance(admin only) -
POST /api/v1/time-tracking/entriesbodyRecordTimeRequest→TimeEntry(creates atime_entryand updates the correspondingovertime_balancein one transaction; idempotent onshift_assignment_id— but balance update has a known double-credit on edit, see follow-up) -
POST /api/v1/time-tracking/payoutsbodyPayoutRequest→PayoutRecord(admin records a payout; subtracts from balance; usesSELECT … FOR UPDATEon the balance row) -
GET /api/v1/time-tracking/payouts/me→Vec<PayoutRecord> -
GET /api/v1/time-tracking/payouts/{user_id}→Vec<PayoutRecord>(admin)
The whole tag is gated by tenant_settings.time_tracking_enabled; handlers return 404 when the feature is off (see time_tracking::guard).
forms
-
GET /api/v1/forms→Vec<CustomerForm>(admin) -
POST /api/v1/formsbodyCreateFormRequest→CustomerForm -
GET /api/v1/forms/{form_id}/submissions→Vec<FormSubmission> -
POST /api/v1/forms/{form_id}/submissions/{sub_id}/convertbodyConvertSubmissionRequest→Event(admin promotes a customer submission into a realeventsrow) -
POST /api/v1/events/{event_id}/documents/uploadbodyRequestUploadRequest→UploadUrlResponse(presigned S3-PUT URL, points at RustFS in dev) -
GET /api/v1/f/{slug}→CustomerForm(customer-facing — usescustomer_forms_lookup_by_slugSECURITY DEFINER function to bypass RLS for slug-to-tenant resolution; handler then verifies the JWT’s tenant matches) -
POST /api/v1/f/{slug}/submitbodySubmitFormRequest→FormSubmission(rate-limited via process-localRateLimiter, default 10/hour/(IP, slug) — seeforms::rate_limit)
notifications
-
GET /api/v1/notifications→Vec<Notification>(caller’s own; ordered bycreated_at DESC) -
POST /api/v1/notifications/{id}/read→ 204 -
POST /api/v1/notifications/read-all→MarkAllReadResponse { marked: i64 } -
POST /api/v1/push-subscriptionsbodyRegisterPushRequest→PushSubscription -
DELETE /api/v1/push-subscriptions/{id}→ 204
notifications::service::create_and_dispatch is the canonical fan-out path — creates a row in notifications and (if a push_subscriptions row exists for the user) sends a Web-Push payload via web-push crate signed with the tenant’s VAPID keypair. Note: no business-logic call sites yet — see P1 follow-up.
Response shape conventions
-
Lists: plain
Vec<T>for now. The frontend has aPage<T>envelope ready infrontend/src/api/types.rs, but the backend doesn’t paginate yet — that’s a future iteration. -
Timestamps:
chrono::DateTime<Utc>serialized as RFC 3339 strings. -
UUIDs: unhyphenated lowercase string in JSON.
-
Tenant id in responses: present on
RoleDefinition,Location,Event,Shift(defensive — frontend ignores it). Will probably be stripped from response DTOs in a future cleanup.
Auth-failure paths
-
Authorizationheader missing → 401, body{"error": "missing bearer token"} -
JWT invalid (signature, expired, wrong issuer) → 401
-
JWT valid but role insufficient (e.g. employee hits an admin route) → 403
-
Cross-tenant access (token from tenant A, resource owned by tenant B) → 404 (RLS makes the row literally invisible — there is no 403 path because the handler never sees the row)