e01abfef27
CI dry-run revealed an architectural ordering bug: db-init/004 and
db-init/005 ALTER TABLE the Directus-managed tables (organization_users,
events, etc.), but db-init runs BEFORE schema-apply creates those
tables. On a fresh CI Postgres this fails with "relation does not
exist." Local dev never tripped this because we'd created the tables
via MCP first.
Fix: introduce a post-schema migration phase. Two db-init runs in the
entrypoint, with schema-apply in between:
1. apply-db-init.sh db-init/ → positions hypertable + faulty
column (tables Directus does
NOT manage)
2. schema-apply.sh → creates Directus-managed tables
from snapshots/schema.yaml
3. apply-db-init.sh db-init-post/ → composite UNIQUE constraints on
the Directus-managed tables
4. directus bootstrap
5. directus start
Files moved:
db-init/004_junction_unique_constraints.sql →
db-init-post/001_junction_unique_constraints.sql
db-init/005_event_participation_unique_constraints.sql →
db-init-post/002_event_participation_unique_constraints.sql
Each ALTER TABLE in the post-schema migrations is now wrapped in a
pg_constraint existence guard for idempotency. This handles the dev DB
where the constraints already exist (from the original 004/005 runs +
the manual psql recovery during task 1.5's destructive-apply
incident). Old 004/005 rows in migrations_applied become orphans —
harmless.
Updates:
- Dockerfile: COPY db-init-post into the image
- entrypoint.sh: 4-step → 5-step flow with the post-schema run between
schema-apply and bootstrap
- .gitea/workflows/build.yml: dry-run chains all three pre-boot scripts
(pre-schema → schema-apply → post-schema); path filter includes
db-init-post/**
- Task specs 1.4 and 1.5 Done sections: updated to reference the new
db-init-post/ path (db-init/004 → db-init-post/001, etc.)
The reusable runner script (apply-db-init.sh) didn't need to change —
it already accepts DB_INIT_DIR and uses just the basename for the
guard-table key. The two phases share migrations_applied; filenames
don't collide because pre-schema and post-schema use distinct
descriptive names.
Phase 1 is still "done" — this is a Phase 1 architectural correction
exposed by the CI dry-run, not a new task.
168 lines
9.5 KiB
Markdown
168 lines
9.5 KiB
Markdown
# Task 1.4 — Org-level catalog collections
|
|
|
|
**Phase:** 1 — Slice 1 schema + deploy pipeline
|
|
**Status:** ⬜ Not started
|
|
**Depends on:** 1.3 (db-init applied so Directus can boot)
|
|
**Wiki refs:** `docs/wiki/synthesis/directus-schema-draft.md` (Org-level catalog section), `docs/wiki/sources/rally-albania-regulations-2025.md`
|
|
|
|
## Goal
|
|
|
|
Create the durable, org-level collections in the Directus admin UI: `organizations`, `users` (using Directus's built-in users with custom fields), `organization_users`, `vehicles`, `organization_vehicles`, `devices`, `organization_devices`. These are the resources that exist independently of any single event.
|
|
|
|
This task happens against a locally running Directus instance (from `pnpm dev`). The output is a snapshot YAML that captures the collection definitions; that snapshot lands in git in task 1.6.
|
|
|
|
## Deliverables
|
|
|
|
Create the following collections via the admin UI (Settings → Data Model). Field shapes per [[directus-schema-draft]]. Required-field columns marked `*`.
|
|
|
|
### `organizations`
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `id` * | UUID | primary key, auto-generated |
|
|
| `name` * | string | display name |
|
|
| `slug` * | string | URL-friendly identifier, unique |
|
|
| `created_at` | timestamp | Directus standard |
|
|
| `updated_at` | timestamp | Directus standard |
|
|
|
|
Singleton: false. Sort: `name asc`.
|
|
|
|
### `users` (extending Directus built-in `directus_users`)
|
|
|
|
Use the built-in user collection. Add custom fields (Settings → Data Model → directus_users):
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `phone` | string | optional |
|
|
| `birth_date` | date | optional, used for age-derived class eligibility (M-5/M-6/M-7) |
|
|
| `nationality` | string | ISO 3166-1 alpha-2 country code |
|
|
|
|
Do NOT add an `organization_id` here — multi-tenancy goes through `organization_users`.
|
|
|
|
### `organization_users` (junction)
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `id` * | UUID | |
|
|
| `organization_id` * | M2O → organizations | |
|
|
| `user_id` * | M2O → directus_users | |
|
|
| `role` * | string (dropdown) | enum: `org-admin`, `race-director`, `marshal`, `timekeeper`, `participant`, `viewer` |
|
|
| `joined_at` | timestamp | default `now()` |
|
|
|
|
Unique constraint: `(organization_id, user_id)` — a user can only have one row per org. Multiple roles per user in same org → not yet (single role per tenant; revisit if needed).
|
|
|
|
### `vehicles`
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `id` * | UUID | |
|
|
| `make` * | string | "Toyota" |
|
|
| `model` * | string | "Land Cruiser 70" |
|
|
| `year` | integer | |
|
|
| `engine_cc` | integer | engine displacement, used for class assignment |
|
|
| `vin` | string | optional |
|
|
| `plate_number` | string | optional |
|
|
| `notes` | text | |
|
|
|
|
No `owner_user_id` / `owner_team_id` — vehicles are org-scoped only, ownership is not modeled (per [[directus-schema-draft]] decision).
|
|
|
|
### `organization_vehicles` (junction)
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `id` * | UUID | |
|
|
| `organization_id` * | M2O → organizations | |
|
|
| `vehicle_id` * | M2O → vehicles | |
|
|
| `registered_at` | timestamp | default `now()` |
|
|
|
|
Unique constraint: `(organization_id, vehicle_id)`.
|
|
|
|
### `devices`
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `id` * | UUID | |
|
|
| `imei` * | string | unique, the canonical device identifier |
|
|
| `model` * | string | "FMB920", "FMB003", etc. — drives IO mapping in [[processor]] |
|
|
| `serial_number` | string | optional |
|
|
| `notes` | text | |
|
|
|
|
`imei` UNIQUE — same IMEI can't be registered twice anywhere in the system.
|
|
|
|
### `organization_devices` (junction)
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `id` * | UUID | |
|
|
| `organization_id` * | M2O → organizations | |
|
|
| `device_id` * | M2O → devices | |
|
|
| `registered_at` | timestamp | default `now()` |
|
|
|
|
Unique constraint: `(organization_id, device_id)`.
|
|
|
|
## Specification
|
|
|
|
- **Use UUIDs for all primary keys** (Directus offers UUID v4 generation natively). Avoids leaking row counts and simplifies cross-env data sync.
|
|
- **All M2O relations have `ON DELETE` set to `RESTRICT`** by default — accidentally deleting an org or vehicle should require the operator to clean up dependents first. Override per-relation only with explicit reason.
|
|
- **No permission policies** — Phase 4 territory. Set every collection to "All Access" → none (admin only) for now.
|
|
- **No interface customization beyond defaults** — the SPA isn't using these collections directly yet, and admin UI usability for operators happens after Phase 4 (when policies define what they see).
|
|
- **Do not commit `.env` or any secrets.** This task only modifies Directus schema, which is captured in the snapshot.
|
|
|
|
## Acceptance criteria
|
|
|
|
- [ ] All seven collections exist in the admin UI with the fields listed above.
|
|
- [ ] Required fields are flagged required.
|
|
- [ ] All unique constraints are enforced (test by trying to create a duplicate row — should error).
|
|
- [ ] M2O relations are visible and clickable in the admin UI's relational fields.
|
|
- [ ] No permission policies attached (admin-only).
|
|
- [ ] Manually create one organization, one user, one organization_user row → the relationships work end-to-end.
|
|
- [ ] `pnpm run schema:snapshot` produces a `snapshots/schema.yaml` with all seven collections present (verified by grep).
|
|
- [ ] Booting a brand-new Directus instance (fresh DB, fresh containers) and running `directus schema apply --yes snapshots/schema.yaml` recreates the seven collections identically.
|
|
|
|
## Risks / open questions
|
|
|
|
- **`directus_users` field additions** — Directus does allow adding fields to its built-in user collection, but the snapshot/apply behavior for those additions has historically been finicky across versions. Verify on the pinned Directus version that custom user fields round-trip cleanly via `schema snapshot` + `schema apply`. If they don't, fall back to a separate `user_profiles` collection M2O'd to `directus_users`.
|
|
- **Slug uniqueness on `organizations`** — Directus enforces this at the field level. Confirm it generates a unique-index DDL in the snapshot.
|
|
|
|
## Done
|
|
|
|
**Implementation landed and live-verified 2026-05-02.** All 7 collections live in Directus, snapshot captured at 53,450 bytes / 2,159 lines.
|
|
|
|
**Driven via the `directus-local` MCP server** rather than the admin UI — same canonical result (`directus_collections` / `directus_fields` / `directus_relations` rows + actual Postgres tables), captured cleanly by `directus schema snapshot`. This was the API-driven path the spec hinted at; sub-agents can't inherit MCP from the parent conversation, so this work was driven directly without delegation.
|
|
|
|
**Created:**
|
|
- `organizations` — 5 fields (id UUID PK, name, slug unique, date_created, date_updated).
|
|
- `vehicles` — 10 fields (id UUID PK, make, model, year, engine_cc, vin, plate_number, notes, date_created, date_updated). No ownership fields.
|
|
- `devices` — 7 fields (id UUID PK, imei UNIQUE, model, serial_number, notes, date_created, date_updated).
|
|
- `directus_users` — 3 custom fields added (phone, birth_date, nationality).
|
|
- `organization_users` — 7 fields (id UUID PK, organization_id M2O, user_id M2O, role enum dropdown with 6 values, joined_at, date_created, date_updated).
|
|
- `organization_vehicles` — 6 fields (id UUID PK, organization_id M2O, vehicle_id M2O, registered_at, date_created, date_updated).
|
|
- `organization_devices` — 6 fields (id UUID PK, organization_id M2O, device_id M2O, registered_at, date_created, date_updated).
|
|
- 6 M2O relations on the junctions, all with `ON DELETE RESTRICT`.
|
|
|
|
**Composite unique constraints landed via `db-init-post/001_junction_unique_constraints.sql`** because Directus's snapshot YAML format does not capture composite unique constraints (only single-column ones via `is_unique`). The migration adds:
|
|
- `organization_users (organization_id, user_id)`
|
|
- `organization_vehicles (organization_id, vehicle_id)`
|
|
- `organization_devices (organization_id, device_id)`
|
|
|
|
Boot logs confirm: `[db-init] apply 004_junction_unique_constraints.sql` → `[db-init] done 004_junction_unique_constraints.sql` → assertion block passes.
|
|
|
|
**Snapshot review (`snapshots/schema.yaml`):**
|
|
- 8 collections registered (the 7 above + `positions` and `migrations_applied` as ghost entries — Directus auto-discovers tables in the public schema and registers minimal metadata for them, even though they're owned by db-init/processor not Directus). The ghost entries are harmless: schema apply against a fresh DB sees them already created by db-init and skips DDL.
|
|
- `directus_users` custom fields round-trip correctly (no need for the spec's fallback `user_profiles` workaround).
|
|
- All 6 M2O relations present in the relations section.
|
|
- File size 53,450 bytes — well under the 200KB sanity threshold.
|
|
|
|
**Acceptance criteria status:**
|
|
- ✅ All seven collections exist with the fields specified.
|
|
- ✅ Required fields flagged (organizations.name/slug, devices.imei/model, vehicles.make/model, junction org/target/role).
|
|
- ✅ Single-column unique constraints (organizations.slug, devices.imei) enforced.
|
|
- ✅ Composite unique constraints on junctions enforced via db-init-post/001 (assertion block confirms).
|
|
- ✅ M2O relations clickable in admin UI (Directus auto-resolves the dropdowns from the relation metadata).
|
|
- ✅ No permission policies attached — admin-only by default.
|
|
- ✅ `pnpm run schema:snapshot` produces snapshots/schema.yaml with all 7 collections present.
|
|
- ⏳ End-to-end test (manually create org → user → org_user via admin UI) — pending user.
|
|
- ⏳ Apply-to-fresh-DB roundtrip — pending CI dry-run in task 1.8.
|
|
|
|
**Phase 5 follow-up note (not blocking):** boot logs still WARN about `positions` lacking a PK. Already documented in task 1.7's Done section.
|