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 /health is unauthenticated.

  • Auth: Bearer JWT (Zitadel-issued), validated by auth_middleware (backend/src/auth/middleware.rs). The middleware injects a TenantContext request extension; every handler that touches the DB extracts it.

  • Tenant scope: every authenticated handler runs its DB work via TenantContext::rls_transaction(&pool), which sets app.tenant_id GUC 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

health

  • GET /health — liveness probe. Returns 200 ok. Outside the /api/v1 nest.

auth

  • GET /api/v1/meMeResponse { user_id, tenant_id, role }. Smoke-test + canonical "who am I".

tenants

  • GET /api/v1/tenant/settingsTenantSettings

  • PATCH /api/v1/tenant/settingsTenantSettings (full-replace semantics today; PATCH-vs-PUT is a P2 follow-up)

roles

  • GET /api/v1/rolesVec<RoleDefinition>

  • POST /api/v1/roles body CreateRoleRequestRoleDefinition

  • DELETE /api/v1/roles/{id} → 204

locations

  • GET /api/v1/locationsVec<Location>

  • POST /api/v1/locations body CreateLocationRequestLocation (server-side Nominatim geocoding fills lat/lng if street + city are present)

  • DELETE /api/v1/locations/{id} → 204

users

  • GET /api/v1/usersVec<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/eventsVec<Event>

  • POST /api/v1/events body CreateEventRequestEvent

  • GET /api/v1/events/{id}Event

  • POST /api/v1/events/{id}/publishEvent (transitions draftpublished)

shifts

  • GET /api/v1/shifts (query: ?status=draft|open|filled|completed, ?event_id=...)Vec<Shift>

  • POST /api/v1/shifts body CreateShiftRequestShift

  • GET /api/v1/shifts/{id}Shift

  • GET /api/v1/shifts/{id}/slotsVec<ShiftSlotWithFill> (slots + computed filled_count; the same RLS transaction guarantees count and slot list can’t drift)

  • POST /api/v1/shifts/{id}/publishShift

  • POST /api/v1/shifts/{id}/split/{n}Vec<Shift> (splits one shift into N sub-shifts)

  • GET /api/v1/events/{event_id}/shiftsVec<Shift> (event-scoped list)

  • POST /api/v1/events/{event_id}/shifts/bulk body BulkGenerateRequestVec<Shift> (generates one shift per day in the event window using start_time/end_time in tenant timezone)

assignments

  • POST /api/v1/shifts/{shift_id}/slots/{slot_id}/claimShiftAssignment (employee self-claim; capacity-checked but currently susceptible to a P1 race — see claim_slot follow-up)

  • POST /api/v1/shifts/{shift_id}/slots/{slot_id}/assign body AssignRequest { user_id }ShiftAssignment (admin assigns)

time-tracking

  • GET /api/v1/time-tracking/balanceOvertimeBalance (caller’s own)

  • GET /api/v1/time-tracking/balancesVec<OvertimeBalance> (admin only)

  • GET /api/v1/time-tracking/balance/{user_id}OvertimeBalance (admin only)

  • POST /api/v1/time-tracking/entries body RecordTimeRequestTimeEntry (creates a time_entry and updates the corresponding overtime_balance in one transaction; idempotent on shift_assignment_id — but balance update has a known double-credit on edit, see follow-up)

  • POST /api/v1/time-tracking/payouts body PayoutRequestPayoutRecord (admin records a payout; subtracts from balance; uses SELECT … FOR UPDATE on the balance row)

  • GET /api/v1/time-tracking/payouts/meVec<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/formsVec<CustomerForm> (admin)

  • POST /api/v1/forms body CreateFormRequestCustomerForm

  • GET /api/v1/forms/{form_id}/submissionsVec<FormSubmission>

  • POST /api/v1/forms/{form_id}/submissions/{sub_id}/convert body ConvertSubmissionRequestEvent (admin promotes a customer submission into a real events row)

  • POST /api/v1/events/{event_id}/documents/upload body RequestUploadRequestUploadUrlResponse (presigned S3-PUT URL, points at RustFS in dev)

  • GET /api/v1/f/{slug}CustomerForm (customer-facing — uses customer_forms_lookup_by_slug SECURITY DEFINER function to bypass RLS for slug-to-tenant resolution; handler then verifies the JWT’s tenant matches)

  • POST /api/v1/f/{slug}/submit body SubmitFormRequestFormSubmission (rate-limited via process-local RateLimiter, default 10/hour/(IP, slug) — see forms::rate_limit)

notifications

  • GET /api/v1/notificationsVec<Notification> (caller’s own; ordered by created_at DESC)

  • POST /api/v1/notifications/{id}/read → 204

  • POST /api/v1/notifications/read-allMarkAllReadResponse { marked: i64 }

  • POST /api/v1/push-subscriptions body RegisterPushRequestPushSubscription

  • 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 a Page<T> envelope ready in frontend/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

  • Authorization header 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)