Data Models — Backend
Source of truth: backend/migrations/0001..0007_*.sql. This document mirrors the schema as of migration 0007. Run docker exec shiftit-pg psql -U shiftit -d shiftit -c "\dt" against a freshly-migrated dev DB to confirm.
Tenancy & RLS
Every tenant-scoped table has:
-
a
tenant_id UUIDcolumn with FK totenants(id) ON DELETE CASCADE, -
ENABLE ROW LEVEL SECURITY, -
a policy named
<table>_isolationwithFOR ALLandWITH CHECK, predicatetenant_id = current_setting('app.tenant_id', true)::uuid, -
explicit
GRANT SELECT, INSERT, UPDATE, DELETE … TO shiftit_app(redundant with default privileges from 0001 but kept for clarity).
The runtime DB pool issues SET ROLE shiftit_app on every physical connection. TenantContext::rls_transaction(&pool) returns a Transaction with app.tenant_id set; queries inside that transaction are RLS-filtered. The connecting user is normally a superuser (would bypass RLS); the SET ROLE hop is what makes the policies bind.
shift_slots and shift_assignments use subquery-based RLS (filter via the parent shifts row’s tenant_id) rather than a denormalised column — fail-closed by construction but a known per-row cost under load (see follow-up: subquery-based RLS).
Tables (by migration)
Migration 0001 — tenants, users
tenants
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
TEXT UNIQUE |
Maps to Zitadel Organization id |
|
TEXT |
Display name |
|
TEXT UNIQUE |
URL-safe slug |
|
JSONB |
Default: |
|
TIMESTAMPTZ |
users
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
CASCADE |
|
TEXT |
UNIQUE per |
|
TEXT |
|
|
TEXT |
|
|
TEXT CHECK |
One of |
|
TIMESTAMPTZ |
Indexes: users_tenant_idx (tenant_id).
Also creates: shiftit_app NOLOGIN role with all-table DML grants and default privileges so future migrations auto-grant.
Migration 0002 — role_definitions, employee_qualifications, locations
role_definitions — tenant-defined role catalogue with optional self-referencing parent for hierarchy chains.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
TEXT |
UNIQUE per |
|
UUID FK → role_definitions |
NULL on root; SET NULL on parent delete |
|
INT |
Hierarchy ordering; higher = more qualified |
|
TIMESTAMPTZ |
employee_qualifications — m:n users ↔ role_definitions.
| Column | Type | Notes |
|---|---|---|
|
UUID FK → users |
CASCADE |
|
UUID FK → role_definitions |
CASCADE |
|
UUID FK → tenants |
CASCADE |
|
TIMESTAMPTZ |
PK (user_id, role_definition_id).
locations — tenant venues with optional Nominatim-geocoded coords.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
TEXT |
|
|
TEXT DEFAULT 'DE' |
|
|
DOUBLE PRECISION |
Filled server-side via |
Migration 0003 — events, shifts, shift_slots, shift_assignments
events — date-range parents of shifts.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
TEXT |
|
|
UUID FK → locations |
SET NULL on delete |
|
TIMESTAMPTZ |
CHECK |
|
TEXT CHECK |
|
|
TEXT CHECK |
|
|
UUID |
Soft reference (no FK) — set when source is |
|
TIMESTAMPTZ |
shifts — concrete time-windowed shifts, optionally parented to an event.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
UUID FK → events |
SET NULL |
|
TEXT |
|
|
TIMESTAMPTZ |
CHECK |
|
TEXT CHECK |
|
|
TEXT CHECK |
|
|
TIMESTAMPTZ |
shift_slots — required role + count per shift.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → shifts |
CASCADE |
|
UUID FK → role_definitions |
|
|
INT DEFAULT 1 |
CHECK |
|
TEXT |
shift_assignments — actual employee assignments to slots.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → shift_slots |
CASCADE |
|
UUID FK → users |
|
|
TEXT CHECK |
|
|
UUID FK → users |
NULL when self-claimed |
|
TIMESTAMPTZ |
UNIQUE (shift_slot_id, user_id) — one user per slot.
Migration 0004 — time_entries, overtime_balances, payout_records
time_entries — recorded actuals per shift assignment.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
UUID FK → users |
|
|
UUID FK → shift_assignments |
UNIQUE — idempotent on assignment |
|
TIMESTAMPTZ |
Snapshot of the shift window |
|
INT |
CHECK |
|
TIMESTAMPTZ |
overtime_balances — running per-user balance.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
UUID FK → users |
UNIQUE per |
|
INT |
Can go negative if payouts exceed accrual |
|
TIMESTAMPTZ |
payout_records — admin-recorded payouts (subtractions from balance).
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
UUID FK → users |
|
|
INT |
CHECK |
|
TEXT |
|
|
UUID FK → users |
The admin who recorded it |
|
TIMESTAMPTZ |
Migration 0005 — customer_forms, form_submissions, event_documents
customer_forms — admin-defined forms exposed at /f/{slug}.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
TEXT |
Internal label |
|
TEXT |
Displayed customer-facing |
|
UUID FK → users |
Optional — if set, only that customer’s JWT can submit |
|
UUID FK → locations |
SET NULL |
|
UUID UNIQUE |
Public URL component; itself a UUID for unguessability |
|
JSONB |
|
|
BOOLEAN |
Soft-disable forms without deleting them |
form_submissions — customer-submitted payloads.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → customer_forms |
CASCADE |
|
UUID FK → tenants |
|
|
UUID FK → users |
NULL if anonymous (not currently a path) |
|
JSONB |
Field-id-keyed map matching |
|
TEXT CHECK |
|
|
UUID FK → events |
Set when admin promotes submission to event |
|
TIMESTAMPTZ |
event_documents — files attached to events (uploaded to RustFS).
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → events |
CASCADE |
|
UUID FK → tenants |
|
|
TEXT |
Display name |
|
TEXT UNIQUE |
S3 key |
|
TEXT |
|
|
BIGINT |
|
|
UUID FK → users |
Migration 0006 — customer_forms_lookup_by_slug function
SECURITY DEFINER SQL function that returns a customer form by slug, bypassing RLS. Used by forms::routes::get_form_for_customer to resolve slug → tenant before the JWT-tenant guard runs. The handler then verifies the JWT’s tenant matches the returned tenant_id. This is the only RLS-bypass in the codebase.
Migration 0007 — notifications, push_subscriptions
notifications — in-app inbox.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → tenants |
|
|
UUID FK → users |
CASCADE |
|
TEXT CHECK |
|
|
JSONB |
Type-specific payload (see |
|
TIMESTAMPTZ |
NULL = unread |
|
TIMESTAMPTZ |
Indexes: (tenant_id, user_id, created_at DESC) for inbox listing; partial (tenant_id, user_id) WHERE read_at IS NULL for unread badge.
push_subscriptions — Web-Push endpoints.
| Column | Type | Notes |
|---|---|---|
|
UUID PK |
|
|
UUID FK → users |
CASCADE |
|
UUID FK → tenants |
|
|
TEXT |
UNIQUE per |
|
TEXT |
Encryption material |
Reference relationships
tenants
├── users
│ └── employee_qualifications ─→ role_definitions
│ └── shift_assignments ─→ shift_slots ─→ shifts ─→ events ─→ locations
│ └── time_entries ─→ shift_assignments
│ └── overtime_balances
│ └── payout_records
│ └── notifications
│ └── push_subscriptions
└── customer_forms
└── form_submissions ─→ events (when promoted)
└── event_documents
Operator notes
-
Migrations 0001 and 0005 were edited after first apply during foundation work. If you see "migration X was previously applied but has been modified" on
cargo run, follow the dev-DB reset inCLAUDE.md. -
.sqlx/offline cache is committed. After adding or changing any sqlx query, runcargo sqlx prepare --workspace -- --testsand commit the resulting.sqlx/changes; CI builds withSQLX_OFFLINE=true. -
No
updated_atcolumns yet outsideovertime_balances. Optimistic concurrency /If-Matchheaders are a P2 follow-up.