Database schema and migrations¶
Every table of the tenant databases and of the master database, with its model and the rules its columns carry, followed by the migration history: the raw SQL files, the runners, and what the tenant and master auto-migrations apply on upgrade. It was moved verbatim out of the repo-root CLAUDE.md; the schema-change checklist stays there. The admin-facing notes are in Database & Migrations.
Code described: app/models/, app/database.py, setup_databases.sql, migrations/, app/services/tenant_provisioner.py, app/services/tenant_manager.py, app/services/tenant_data_migrations.py, app/services/master_db.py, scripts/sql/tenant_schema_template.sql.
Database Schema¶
Two declarative bases: Base (tenant databases, app/database.py) and MasterBase (master database, app/models/tenant.py). Never mix them.
Tenant database tables (one DB per tenant: milestone_<slug>)¶
Users & access
| Table | Model | Purpose |
|---|---|---|
users |
User |
App users and staff resources. email (unique), password, names, job_title, role CHECK(admin/superuser/user), max_capacity % (part-time), SSO fields, active, is_system (protects provisioned admin). Props: full_name, is_admin, site_ids, can_modify_site() |
user_sites |
UserSite |
M:N user ↔ site (composite PK) |
sessions |
Session |
express-session-compatible session store: sid PK, sess JSON text, expired (ms epoch) |
sso_config |
SSOConfig |
Tenant-level SSO singleton (CHECK id = 1): enabled, Entra tenant/client id, client_secret (AES-256-GCM encrypted, legacy plaintext encrypted by the auto-migration — use the sso.py helpers), redirect URI, auto_create_users, default_role |
Sites & calendar
| Table | Model | Purpose |
|---|---|---|
sites |
Site |
Physical locations: name (unique), location, city, country/region codes, timezone, active, last_holiday_fetch |
bank_holidays |
BankHoliday |
Public holidays per site (affect working-day calc); date + optional end_date (multi-day), year (NOT NULL); is_custom for manual, imported or edited entries (editing a Nager row sets is_custom = 1); refreshable from the Nager API — a refresh replaces only non-custom rows, only for the years the API actually returned (a failed year keeps its rows; replace_existing also clears stale years), and skips Nager entries on any day a custom holiday covers, or sharing a name (trimmed, case-insensitive) with a custom holiday of the same year (moved holidays) |
company_events |
CompanyEvent |
Audits/meetings with color — displayed but do not affect working-day calc. counts_as_unavailable (BOOLEAN NOT NULL DEFAULT FALSE) marks a site closure: staff of the event's sites are unavailable for its dates (evaluated client-side). site_id is legacy (nullable, FK ON DELETE SET NULL), kept equal to the first linked site; site links live in company_event_sites |
company_event_sites |
CompanyEventSite |
M:N event ↔ site (composite PK, both FKs CASCADE). Deleting a site deletes events left with no site |
Projects
| Table | Model | Purpose |
|---|---|---|
projects |
Project |
R&D projects: name, site_id (FK ON DELETE CASCADE — deleting a site deletes its projects), customer, pm_id (FK users ON DELETE SET NULL), sales_pm (free text), confirmed, volume, dates, notes, archived, updated_at; tags M:N |
project_phases |
ProjectPhase |
Phases: project_id (CASCADE), type (name), dates, is_milestone, sort_order, completion, status (Kanban column), dependencies (JSON text) |
project_subphases |
ProjectSubphase |
Recursive subphases: parent_id + parent_type CHECK(phase/subphase), depth, dates, milestone/completion/status/dependencies |
tag_groups |
TagGroup |
Tag categories: name (unique), single_select, sort_order. Colouring the Gantt by a group is a per-user frontend preference (viewStore.ganttColorBy), not stored here |
tags / project_tags |
Tag / ProjectTag |
Project tags: name (unique tenant-wide), color, group_id (FK tag_groups ON DELETE SET NULL), all_sites (default true), sort_order + M:N join with projects |
tag_sites |
TagSite |
M:N tag ↔ site, consulted only when all_sites = false (both FKs CASCADE). Deleting a site deletes restricted tags left with no site |
custom_columns |
CustomColumn |
User-defined Gantt columns: column_type CHECK(text/boolean/list), list_options JSON, site_id (NULL = global), display_order, width |
custom_column_values |
CustomColumnValue |
EAV values: entity_type CHECK(project/phase/subphase) + entity_id, unique per (column, entity). No FK to the entity: project/site/phase/subphase deletes remove them explicitly, and orphans are cleaned by the tenant data migrations |
Assignments (three staff levels + equipment)
| Table | Model | Purpose |
|---|---|---|
project_assignments |
ProjectAssignment |
Project-level staff: allocation %, own start/end dates |
phase_staff_assignments |
PhaseStaffAssignment |
Phase-level staff: allocation only (dates come from the phase) |
subphase_staff_assignments |
SubphaseStaffAssignment |
Subphase-level staff: allocation only |
equipment_assignments |
EquipmentAssignment |
Equipment bookings: project + equipment + dates (project-level only; tenants provisioned before 1.0.15 may carry unused legacy phase/subphase columns) |
Resources & time-off
| Table | Model | Purpose |
|---|---|---|
equipment |
Equipment |
Lab equipment: name, type (free-form string, managed via /equipment-types). site_id is legacy "primary site" (FK ON DELETE SET NULL), kept equal to the lowest linked site id; site links live in equipment_sites |
equipment_sites |
EquipmentSite |
M:N equipment ↔ site (composite PK, both FKs CASCADE, index idx_equipment_sites_site). Deleting a site deletes only equipment left with no site and re-points survivors' site_id (Site.equipment has no delete cascade) |
equipment_blocks |
EquipmentBlock |
Maintenance/unavailability windows (the vacation analogue for equipment) |
vacations |
Vacation |
Staff time-off (absences). absence_type_id FK → absence_types (nullable, ON DELETE SET NULL; NULL = treated as unavailable). Recurring absences are encoded in description as [R:0,2,4] (days of week, 0=Sun) — parsed by frontend/src/utils/recurringVacation.ts |
absence_types |
AbsenceType |
Tenant-wide absence categories: name (unique), color #RRGGBB, counts_as_unavailable (false = shown but capacity not reduced, e.g. Home Office), sort_order, is_active. Seeded when empty: Holidays, Home Office, Illness, Business Trip, Training |
skills / user_skills |
Skill / UserSkill |
Global skills + M:N with proficiency CHECK(1–5) |
Misc
| Table | Model | Purpose |
|---|---|---|
card_comments |
CardComment |
Comments on Kanban cards. Polymorphic (entity_type, entity_id) → phase/subphase, entity_type CHECK(phase/subphase) (same shape as custom_column_values, so no FK on entity_id — phase/subphase deletes must clear them explicitly; orphans are cleaned by the tenant data migrations). project_id FK ON DELETE CASCADE. body stores @[Name](id) mention tokens; mentioned_user_ids (TEXT, JSON id list) is derived from them server-side. author_id is nullable, FK ON DELETE SET NULL (comments survive user deletion; UI shows "Deleted user") + created_at/updated_at. Indexes idx_card_comments_entity, idx_card_comments_project |
notifications |
Notification |
Per-recipient inbox: user_id (FK CASCADE), type CHECK(assigned/comment/mention/status_change), actor_id (SET NULL), entity refs, project_id (FK CASCADE), rendered title/body, read_at. Indexes: partial idx_notifications_user_unread + idx_notifications_user_created. Due-soon/overdue are derived client-side, never stored |
staff_notes |
Note |
Notes pinned to a site/date. Class is Note but tablename is staff_notes. All schema sources now create staff_notes; a legacy notes table on old installs is migrated and dropped by migrations/add_staff_notes.sql / the tenant auto-migration |
settings |
Settings |
Instance key-value settings (e.g. instance_title, show_weekends) |
predefined_phases |
PredefinedPhase |
Phase name templates offered in the UI |
project_presence |
ProjectPresence |
Active-viewer rows (60s timeout). Written by the WS layer; presence has no HTTP API |
Master database tables (MASTER_DB_NAME, app default milestone_master)¶
| Table | Model | Purpose |
|---|---|---|
tenants |
Tenant |
Tenant registry: UUID id, name, slug (unique), database_name/user, status (active/suspended/pending/archived — TenantStatus Literal in app/schemas/tenant.py; create/update/status/delete/provision reset the tenant cache, and all but create also close the pool, so changes apply immediately), plan/limits, organization_id, required_group_ids (JSONB) + group_membership_mode (any/all) for SSO group gating |
tenant_credentials |
TenantCredentials |
Per-tenant DB password, AES-256-GCM encrypted (iv:tag:ciphertext hex) |
tenant_audit_log |
TenantAuditLog |
Admin actions on tenants (actor, action, JSONB details) |
admin_users |
AdminUser |
Portal admins: email, PBKDF2-SHA512 password hash (pbkdf2_sha512$600000$salt$hash; legacy salt:hash upgraded on login), role (admin/superadmin, Literal-validated), must_change_password (enforced by get_current_admin) |
admin_sessions |
AdminSession |
Admin-portal session store (same shape as tenant sessions) |
organizations |
Organization |
Groups tenants for shared SSO |
organization_sso_config |
OrganizationSSOConfig |
Org-level Entra SSO: tenant/client id, encrypted secret, redirect URI, auto-create + default role |
Migrations¶
- Raw idempotent SQL files in
migrations/— no Alembic. Seemigrations/README.md. Shell wrappersmigrations/run_migration.shandmigrations/migrate_all_tenants.shexist for container use. - Tenant migrations:
python migrations/run_migration.py <name>— single-tenant: runs againstDB_NAME; multi-tenant: iterates everyactivetenant from the master DB, connecting withPG_ADMIN_*(preferred) or the decrypted per-tenant credentials. - Master migrations:
python migrations/run_migration_master.py <name>— statement-by-statement execution viasplit_sql_statements(), which correctly preservesDO $$ ... END $$;blocks (covered bytests/test_migration_parser.py). - The list of available migrations is the set of
.sqlfiles inmigrations/— run either runner with no argument to list them. One stray legacy migration lives atscripts/sql/migrations/001_add_max_capacity.sql. - Auto-migrations run in two places on top of the SQL files:
master_db._apply_pending_migrations()(master schema drift, on startup) andtenant_manager._run_auto_migrations()(per-tenant drift, on first pool creation — and, in single-tenant mode, at startup viarun_single_tenant_auto_migrations()in the lifespan). Both are idempotent. The tenant one ends with two non-DDL steps:encrypt_tenant_sso_secret()(legacy plaintextsso_config.client_secret→ encrypted; needs the in-process key, so it cannot be a SQL file) and the Python data-migration hookrun_data_migrations()(app/services/tenant_data_migrations.py: guarded FK rewritesprojects.site_id→ CASCADE,card_comments.author_id→ nullable SET NULL, then orphan cleanup ofcustom_column_values/card_comments). Each step runs in its own transaction and never raises. The SQL lives intenant_provisionerconstants shared with the fresh-install schema and the.sqlfiles — add new data fixes there, not inline. - 1.6.0 migration:
add_tag_groups_and_sites.sql(tag_groups,tag_sites, the three newtagscolumns; also applied by the tenant auto-migration via the provisioner'sTAGS_UPGRADE_SQL). - 1.5.0 migrations:
add_absence_types_and_event_sites.sql(absence_types,vacations.absence_type_id,company_event_sites),add_equipment_sites_and_site_closures.sql(equipment_sites,company_events.counts_as_unavailable), plusprojects_site_fk_cascade.sql,card_comments_author_set_null.sql,cleanup_orphaned_values.sql(all also applied automatically by the tenant auto-migration and the hook above).migrations/migrate_all_tenants.shnow requires a migration file argument; the legacyrun_migration_all_tenants.sql/generate_tenant_migrations.sqlhelpers (which created a wrong-shapedcompany_events) were removed. - 1.4.0 migration:
add_kanban_tables.sql(status+ CHECK onproject_phases/project_subphases,card_comments,notifications). upgrade_to_v90.sqlis a legacy pre-1.0 catch-up script, superseded by the tenant auto-migrations; it adds nothing the models lack (tests/test_follow_up_defaults.pyguards that).setup_databases.sqlis the canonical fresh-install schema and must stay in sync with the models (see the schema-change checklist inCLAUDE.md).