Files
directus/.planning/phase-1-slice-1-schema/05-event-participation-collections.md
T
julian 52524eb72d Task 1.5 — Event-participation collections
Five collections + 10 relations + 5 composite unique constraints,
captured into snapshots/schema.yaml (now 105 KB, up from 53 KB).

Collections:
- 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 for foot
                  races) /class_id M2O, race_number, status enum with
                  8 lifecycle values, archive on `withdrawn`.
                  team_id deliberately omitted (Phase 2+).
- entry_crew   — junction with role enum
                  (pilot/co-pilot/navigator/mechanic/rider/runner/hiker).
- entry_devices — junction with optional assigned_user_id (panic button
                  body-wear); ON DELETE SET NULL on that field since
                  user removal shouldn't block the device record.

10 M2O relations wired, all ON DELETE RESTRICT except
entry_devices.assigned_user_id (SET NULL).

db-init/005_event_participation_unique_constraints.sql adds composite
UNIQUE on:
  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)

---

Destructive-apply incident (recovered):

First attempt at this task hit a real foot-gun. After creating the 5
collections via MCP, we ran `compose build && up -d`. The image rebuild
baked in the snapshot from task 1.4 (only 7 collections). Boot's
schema-apply step ran `directus schema apply --yes` against that stale
snapshot — saw the 5 new collections in the DB but not in the snapshot
— DELETED THEM, taking the constraints with them.

Recovery: re-created the 5 collections + 10 relations via MCP, ran the
ALTER TABLE statements directly via psql to restore the constraints,
ran schema:snapshot BEFORE any further restart so the YAML reflects
the live state. Documented the operator rule (never rebuild with
uncommitted schema changes) inline in the task spec and in the
directus wiki entity page (separate commit in trm/docs).

Phase 3 hardening on the radar: DIRECTUS_SCHEMA_APPLY_MODE env var
with auto/dry-run/skip modes so dev environments default to non-
destructive behavior.

ROADMAP marks 1.5 done. Phase 1 progress: 7/9 tasks complete (1.1–1.7);
1.8, 1.9 remain.
2026-05-02 09:55:17 +02:00

192 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 1199 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/005_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/005_*.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/005 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/005_*.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/005.
- ✅ 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.