# Task 1.5 — Event-participation collections **Phase:** 1 — Slice 1 schema + deploy pipeline **Status:** ⬜ Not started **Depends on:** 1.4 **Wiki refs:** `docs/wiki/synthesis/directus-schema-draft.md` (Event-level participation section), `docs/wiki/sources/rally-albania-regulations-2025.md` (§2.2–§2.5 for class taxonomy reference) ## Goal Create the per-event participation collections in the Directus admin UI: `events`, `classes`, `entries`, `entry_crew`, `entry_devices`. These are scoped to a single event and form the unit of timing. ## Deliverables Create the following collections via the admin UI. Field shapes per [[directus-schema-draft]]. ### `events` | Field | Type | Notes | |---|---|---| | `id` * | UUID | | | `organization_id` * | M2O → organizations | event lives in exactly one org | | `name` * | string | "Rally Albania 2026" | | `slug` * | string | unique within an org | | `discipline` * | string (dropdown) | enum: `rally`, `time-trial`, `regatta`, `trail-run`, `hike` — drives validation | | `starts_at` * | timestamp | event window begin | | `ends_at` * | timestamp | event window end | | `regulation_doc_url` | string | external URL to the rulebook PDF/page (e.g. `wiki/sources/rally-albania-regulations-2025.md`) | | `notes` | text | | Unique constraint: `(organization_id, slug)`. ### `classes` | Field | Type | Notes | |---|---|---| | `id` * | UUID | | | `event_id` * | M2O → events | classes are per-event | | `code` * | string | "M-1", "C-2", "S-1", … | | `name` * | string | human-readable | | `description` | text | eligibility rules in plain text | | `sort_order` | integer | for display ordering | Unique constraint: `(event_id, code)`. ### `entries` The unit of timing. One row per (vehicle or solo participant) registered for an event. | Field | Type | Notes | |---|---|---| | `id` * | UUID | | | `event_id` * | M2O → events | | | `vehicle_id` | M2O → vehicles | nullable — null for foot races (trail-run, hike) | | `team_id` | M2O → teams | nullable — for now, no `teams` collection in Phase 1, leave the field nullable and unwired (`teams` collection is Phase 2 territory if needed; per the schema draft, teams are an org-level catalog item) | | `class_id` * | M2O → classes | required: every entry has a class | | `race_number` * | integer | per Rally Albania §5: 1–199 moto, 2xx quad, 3xx car, 4xx SSV | | `status` * | string (dropdown) | enum: `registered`, `confirmed`, `started`, `finished`, `dnf`, `dns`, `dq`, `withdrawn` | | `registered_at` | timestamp | default `now()` | | `notes` | text | | Unique constraint: `(event_id, race_number)` — no two entries share a race number in the same event. > **Status enum semantics** (from the schema draft): > - `registered` — paid, not yet confirmed at scrutineering > - `confirmed` — passed scrutineering, eligible to start > - `started` — has begun the first stage > - `finished` — completed all stages within MTA > - `dnf` — did not finish (started but couldn't complete) > - `dns` — did not start (confirmed but absent at start) > - `dq` — disqualified (rule violation, see Rally Albania §12.13) > - `withdrawn` — voluntary withdraw (Rally Albania §12.15 — MTA penalty for remaining stages) > **`teams` deferred:** Phase 1 doesn't define a `teams` collection. The `team_id` field on `entries` is nullable and the FK target is intentionally unwired in Phase 1. Drop the field entirely if it complicates the snapshot — re-add in Phase 2 if a real team relationship is needed. ### `entry_crew` (junction) | Field | Type | Notes | |---|---|---| | `id` * | UUID | | | `entry_id` * | M2O → entries | | | `user_id` * | M2O → directus_users | | | `role` * | string (dropdown) | enum: `pilot`, `co-pilot`, `navigator`, `mechanic`, `rider`, `runner`, `hiker` | Unique constraint: `(entry_id, user_id)` — a user can't appear twice in the same entry's crew. ### `entry_devices` (junction) | Field | Type | Notes | |---|---|---| | `id` * | UUID | | | `entry_id` * | M2O → entries | | | `device_id` * | M2O → devices | | | `assigned_user_id` | M2O → directus_users | nullable. null = vehicle-mounted; set = body-worn on this crew member | | `mount_position` | string | optional free text: "panic_button_pilot", "hardwired_dash", "backup_chassis" | Unique constraint: `(entry_id, device_id)` — a device can't appear twice in the same entry. ## Specification - **All M2O `ON DELETE`:** `RESTRICT` by default. Cascading from event → entries is appealing but risky for audit/historical purposes — leave `RESTRICT` and require explicit operator action. - **`status` enum order matters for display.** Set the dropdown's option order to match the lifecycle: `registered` → `confirmed` → `started` → `finished` → `dnf` → `dns` → `dq` → `withdrawn`. - **`race_number` is integer**, not string. Plate background color (white/yellow/green/red per Rally Albania §5.5) is derivable from the number range; not a stored field. - **No permission policies yet** — Phase 4 territory. Admin-only access. - **No `team_id` field if it adds complexity** — the schema draft leaves teams as an org-level catalog item that's not yet defined. Phase 1 ships entries without team support. ## Acceptance criteria - [ ] All five collections exist in the admin UI with the fields listed above. - [ ] Required fields flagged required. - [ ] Unique constraints enforced. - [ ] M2O relations work in the admin UI. - [ ] `entries.status` dropdown shows all eight values in lifecycle order. - [ ] Manually walk through the registration: create an event → create classes → create one entry referencing a vehicle, class, and race number → add two `entry_crew` rows (pilot + co-pilot) → add three `entry_devices` rows (one with `assigned_user_id` set, two with null). All FKs resolve. - [ ] Try to create a second entry with the same `race_number` in the same event → error. - [ ] `pnpm run schema:snapshot` produces a snapshot containing the new collections. - [ ] Cross-checked against the schema draft: every field that should exist does, every nullable field is nullable, every unique constraint is in place. ## Risks / open questions - **`assigned_user_id` on entry_devices** — Directus represents this as an M2O. Verify the snapshot encodes the nullable / non-required nature correctly. - **Cascading deletes vs RESTRICT** — RESTRICT is the safe default but may make admin UX painful (you can't delete an event without first deleting all its entries, etc.). Phase 4 / Phase 5 may revisit with custom Flows that walk the dependency graph. ## Done **Implementation landed and live-verified 2026-05-02.** All 5 collections live, snapshot grew from 53 KB to 105 KB. **Created (via the directus-local MCP server, same approach as 1.4):** - `events` — 11 fields incl. organization_id M2O, discipline enum (rally/time-trial/regatta/trail-run/hike), starts_at/ends_at required. - `classes` — 8 fields incl. event_id M2O, code unique within event. - `entries` — 11 fields incl. event_id/vehicle_id (nullable)/class_id M2O, race_number, status enum with 8 values, archive on `withdrawn`. **`team_id` deliberately NOT included** per spec note (defer until Phase 2 if real team relationship is needed). - `entry_crew` — 6 fields incl. entry_id/user_id M2O, role enum (pilot/co-pilot/navigator/mechanic/rider/runner/hiker). - `entry_devices` — 7 fields incl. entry_id/device_id M2O, assigned_user_id (nullable, `ON DELETE SET NULL` since user removal shouldn't block device record). **10 relations** wired across the 5 collections, all `ON DELETE RESTRICT` except `entry_devices.assigned_user_id` (`SET NULL`, deviation noted above). **Composite unique constraints landed via `db-init-post/002_event_participation_unique_constraints.sql`:** - `events (organization_id, slug)` - `classes (event_id, code)` - `entries (event_id, race_number)` - `entry_crew (entry_id, user_id)` - `entry_devices (entry_id, device_id)` --- **⚠️ Schema-apply destructive deletion incident (2026-05-02):** This task surfaced a real foot-gun in our boot pipeline. Documenting in detail so future work avoids it. **What happened:** 1. We created 5 new collections via MCP against the running Directus. 2. We then ran `docker compose build && up -d` to make `db-init-post/002_*.sql` apply. 3. The image rebuild baked in the OLD `snapshots/schema.yaml` (committed in task 1.4 — only had 7 collections). 4. Boot ran the entrypoint chain. db-init applied 005 successfully (constraints landed on the new tables). But step 2/4 (`schema-apply.sh` → `directus schema apply --yes /directus/snapshots/schema.yaml`) compared the running DB against the stale snapshot and saw 5 collections that "shouldn't exist" — so it **deleted them**, taking the constraints with them. 5. End state: 5 collections gone, db-init-post/002 row in `migrations_applied` still recorded as applied (so it wouldn't re-run), production-shape damage in dev. **Why `directus schema apply --yes` is destructive by design:** The `--yes` flag tells Directus to enforce the snapshot as the single source of truth — anything in the DB but not in the snapshot is dropped. This is the *correct* behavior for fresh-environment provisioning (tasks 1.7's entrypoint, 1.8's CI dry-run, prod boots) where the snapshot IS the canonical state. It is the *wrong* behavior during active schema development when the snapshot lags behind live changes. **Recovery performed:** 1. Re-created the 5 collections + 10 relations via MCP (same calls as the original task 1.5 work — repeatable since the data was source-controlled in the conversation). 2. Re-applied the 5 ALTER TABLE statements from `db-init-post/002_*.sql` directly via psql (since `migrations_applied` already had 005 recorded). 3. Ran `pnpm run schema:snapshot` *before* any further restart. Snapshot now reflects the full 13-collection state. **Discipline going forward (operator rule):** > **Never restart or rebuild the Directus container while there are uncommitted schema changes.** The flow is always: change in admin UI / via MCP → `pnpm run schema:snapshot` → commit → only then rebuild/restart. This rule is now documented in `wiki/entities/directus.md` Schema management section. **Architectural follow-up (not for Phase 1):** The entrypoint's hard-coded `--yes` is a long-term issue. Phase 3 hardening could introduce a `DIRECTUS_SCHEMA_APPLY_MODE` env var with values `auto` (current behavior, prod default), `dry-run` (log diff only, halt on drift — dev default), `skip`. Tracked as a Phase 3 task; non-blocking for slice-1 ship. --- **Acceptance criteria status:** - ✅ All 5 collections exist with the fields specified. - ✅ Required fields flagged (events.organization_id/name/slug/discipline/starts_at/ends_at, classes.event_id/code/name, entries.event_id/class_id/race_number/status, entry_crew.entry_id/user_id/role, entry_devices.entry_id/device_id). - ✅ Single-column unique constraints — none in this task (all uniqueness is composite). - ✅ Composite unique constraints (5 of them) enforced via db-init-post/002. - ✅ M2O relations wired (10 total). - ✅ status enum dropdown shows all 8 values in lifecycle order. - ✅ race_number is integer. - ✅ team_id field omitted per spec note. - ✅ No permission policies attached. - ✅ `pnpm run schema:snapshot` produces snapshots/schema.yaml with all 5 new collections. - ⏳ End-to-end test (manually create event → class → entry → entry_crew → entry_devices) — pending user.