diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7dfed58..9c36eea 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -42,7 +42,7 @@ These rules govern every task. Any deviation must be discussed and documented as ### Phase 1 โ€” Slice 1 schema + deploy pipeline -**Status:** ๐ŸŸจ In progress (1.1, 1.2, 1.3, 1.4, 1.6, 1.7 done; 1.5, 1.8, 1.9 remaining) +**Status:** ๐ŸŸจ In progress (1.1โ€“1.7 done; 1.8, 1.9 remaining) **Outcome:** A Directus instance with the org-level catalog (orgs, users, organization_users, vehicles, devices and their org junctions) and event-participation collections (events, classes, entries, entry_crew, entry_devices) live and snapshot-tracked. `db-init/` covers the TimescaleDB extension, the `positions` hypertable, and the `faulty` column. Image builds via Gitea Actions with a CI dry-run that catches snapshot drift before deploy. Rally Albania 2026 is registered as the first event in admin UI to dogfood the registration workflow. **This is what Rally Albania 2026 needs.** [**See `phase-1-slice-1-schema/README.md`**](./phase-1-slice-1-schema/README.md) @@ -53,7 +53,7 @@ These rules govern every task. Any deviation must be discussed and documented as | 1.2 | [db-init runner script](./phase-1-slice-1-schema/02-db-init-runner.md) | ๐ŸŸฉ | pending user commit | | 1.3 | [Initial migrations (extensions, positions hypertable, faulty column)](./phase-1-slice-1-schema/03-initial-migrations.md) | ๐ŸŸฉ | pending user commit | | 1.4 | [Org-level catalog collections](./phase-1-slice-1-schema/04-org-catalog-collections.md) | ๐ŸŸฉ | pending user commit | -| 1.5 | [Event-participation collections](./phase-1-slice-1-schema/05-event-participation-collections.md) | โฌœ | โ€” | +| 1.5 | [Event-participation collections](./phase-1-slice-1-schema/05-event-participation-collections.md) | ๐ŸŸฉ | pending user commit | | 1.6 | [Schema snapshot/apply tooling](./phase-1-slice-1-schema/06-snapshot-tooling.md) | ๐ŸŸฉ | pending user commit | | 1.7 | [Image build & entrypoint](./phase-1-slice-1-schema/07-image-and-dockerfile.md) | ๐ŸŸฉ | pending user commit | | 1.8 | [Gitea CI dry-run workflow](./phase-1-slice-1-schema/08-gitea-ci-dryrun.md) | โฌœ | โ€” | diff --git a/.planning/phase-1-slice-1-schema/05-event-participation-collections.md b/.planning/phase-1-slice-1-schema/05-event-participation-collections.md index f7dd8ca..9ac66ed 100644 --- a/.planning/phase-1-slice-1-schema/05-event-participation-collections.md +++ b/.planning/phase-1-slice-1-schema/05-event-participation-collections.md @@ -122,4 +122,70 @@ Unique constraint: `(entry_id, device_id)` โ€” a device can't appear twice in th ## Done -(Fill in commit SHA + one-line note when this lands.) +**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. diff --git a/db-init/005_event_participation_unique_constraints.sql b/db-init/005_event_participation_unique_constraints.sql new file mode 100644 index 0000000..2b54b38 --- /dev/null +++ b/db-init/005_event_participation_unique_constraints.sql @@ -0,0 +1,65 @@ +-- 005_event_participation_unique_constraints.sql +-- Composite UNIQUE constraints on the event-participation collections. +-- +-- Same rationale as 004: Directus's `is_unique` flag is single-column only; +-- composite uniqueness lives in db-init/ because the snapshot YAML format +-- does not capture multi-column unique constraints. +-- +-- Owned by: task 1.5 (event-participation collections). + +ALTER TABLE events + ADD CONSTRAINT events_org_slug_unique + UNIQUE (organization_id, slug); + +ALTER TABLE classes + ADD CONSTRAINT classes_event_code_unique + UNIQUE (event_id, code); + +ALTER TABLE entries + ADD CONSTRAINT entries_event_race_number_unique + UNIQUE (event_id, race_number); + +ALTER TABLE entry_crew + ADD CONSTRAINT entry_crew_entry_user_unique + UNIQUE (entry_id, user_id); + +ALTER TABLE entry_devices + ADD CONSTRAINT entry_devices_entry_device_unique + UNIQUE (entry_id, device_id); + +-- ------------------------------------------------------------------------- +-- Assertion block: verify all five constraints landed. +-- ------------------------------------------------------------------------- +DO $$ BEGIN + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'events_org_slug_unique' + ) THEN + RAISE EXCEPTION 'events composite unique constraint (org, slug) missing'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'classes_event_code_unique' + ) THEN + RAISE EXCEPTION 'classes composite unique constraint (event, code) missing'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'entries_event_race_number_unique' + ) THEN + RAISE EXCEPTION 'entries composite unique constraint (event, race_number) missing'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'entry_crew_entry_user_unique' + ) THEN + RAISE EXCEPTION 'entry_crew composite unique constraint (entry, user) missing'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'entry_devices_entry_device_unique' + ) THEN + RAISE EXCEPTION 'entry_devices composite unique constraint (entry, device) missing'; + END IF; + +END $$; diff --git a/snapshots/schema.yaml b/snapshots/schema.yaml index a61e3bd..65aaaaa 100644 --- a/snapshots/schema.yaml +++ b/snapshots/schema.yaml @@ -2,6 +2,32 @@ version: 1 directus: 11.17.4 vendor: postgres collections: + - collection: classes + meta: + accountability: all + archive_app_filter: true + archive_field: null + archive_value: null + collapse: open + collection: classes + color: null + display_template: '{{code}} โ€” {{name}}' + group: null + hidden: false + icon: category + item_duplication_fields: null + note: >- + Per-event classification (e.g. M-1, C-2, S-3 in Rally Albania). Class + drives standings grouping. + preview_url: null + singleton: false + sort: 8 + sort_field: null + translations: null + unarchive_value: null + versioning: false + schema: + name: classes - collection: devices meta: accountability: all @@ -28,6 +54,110 @@ collections: versioning: false schema: name: devices + - collection: entries + meta: + accountability: all + archive_app_filter: true + archive_field: status + archive_value: withdrawn + collapse: open + collection: entries + color: null + display_template: '#{{race_number}} โ€” {{class_id.code}}' + group: null + hidden: false + icon: how_to_reg + item_duplication_fields: null + note: >- + The unit of timing. One row per (vehicle or solo participant) registered + for an event. team_id deferred until Phase 2. + preview_url: null + singleton: false + sort: 9 + sort_field: null + translations: null + unarchive_value: registered + versioning: false + schema: + name: entries + - collection: entry_crew + meta: + accountability: all + archive_app_filter: true + archive_field: null + archive_value: null + collapse: open + collection: entry_crew + color: null + display_template: '{{user_id.first_name}} {{user_id.last_name}} โ€” {{role}}' + group: null + hidden: false + icon: groups + item_duplication_fields: null + note: >- + Junction: which users participate in which entries with what racing + role. + preview_url: null + singleton: false + sort: 10 + sort_field: null + translations: null + unarchive_value: null + versioning: false + schema: + name: entry_crew + - collection: entry_devices + meta: + accountability: all + archive_app_filter: true + archive_field: null + archive_value: null + collapse: open + collection: entry_devices + color: null + display_template: '{{device_id.imei}} โ€” {{mount_position}}' + group: null + hidden: false + icon: sensors + item_duplication_fields: null + note: >- + Junction: which devices are mounted on which entry, optionally body-worn + on a specific crew member. + preview_url: null + singleton: false + sort: 11 + sort_field: null + translations: null + unarchive_value: null + versioning: false + schema: + name: entry_devices + - collection: events + meta: + accountability: all + archive_app_filter: true + archive_field: null + archive_value: null + collapse: open + collection: events + color: null + display_template: '{{name}}' + group: null + hidden: false + icon: event + item_duplication_fields: null + note: >- + An event = a single rally / time-trial / regatta / etc. Lives in exactly + one organization. The container for classes, entries, course definition. + preview_url: null + singleton: false + sort: 7 + sort_field: null + translations: null + unarchive_value: null + versioning: false + schema: + name: events - collection: migrations_applied meta: accountability: all @@ -203,6 +333,333 @@ collections: schema: name: vehicles fields: + - collection: classes + field: id + type: uuid + meta: + collection: classes + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + searchable: true + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: classes + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: classes + field: event_id + type: uuid + meta: + collection: classes + conditions: null + display: null + display_options: null + field: event_id + group: null + hidden: false + interface: select-dropdown-m2o + note: Event this class belongs to + options: + template: '{{name}}' + readonly: false + required: true + searchable: true + sort: 2 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: event_id + table: classes + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: events + foreign_key_column: id + - collection: classes + field: code + type: string + meta: + collection: classes + conditions: null + display: null + display_options: null + field: code + group: null + hidden: false + interface: input + note: >- + Short code (e.g. "M-1", "C-2"). Unique within the event (composite + unique via db-init/005). + options: null + readonly: false + required: true + searchable: true + sort: 3 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: code + table: classes + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: classes + field: name + type: string + meta: + collection: classes + conditions: null + display: null + display_options: null + field: name + group: null + hidden: false + interface: input + note: Human-readable name (e.g. "MOTO Under 450cc") + options: null + readonly: false + required: true + searchable: true + sort: 4 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: name + table: classes + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: classes + field: description + type: text + meta: + collection: classes + conditions: null + display: null + display_options: null + field: description + group: null + hidden: false + interface: input-multiline + note: Eligibility rules in plain text + options: null + readonly: false + required: false + searchable: true + sort: 5 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: description + table: classes + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: classes + field: sort_order + type: integer + meta: + collection: classes + conditions: null + display: null + display_options: null + field: sort_order + group: null + hidden: false + interface: input + note: Display ordering within event + options: null + readonly: false + required: false + searchable: true + sort: 6 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: sort_order + table: classes + data_type: integer + default_value: null + max_length: null + numeric_precision: 32 + numeric_scale: 0 + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: classes + field: date_created + type: timestamp + meta: + collection: classes + conditions: null + display: null + display_options: null + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 7 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: classes + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: classes + field: date_updated + type: timestamp + meta: + collection: classes + conditions: null + display: null + display_options: null + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 8 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: classes + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null - collection: devices field: id type: uuid @@ -611,6 +1068,1451 @@ fields: has_auto_increment: false foreign_key_table: null foreign_key_column: null + - collection: entries + field: id + type: uuid + meta: + collection: entries + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + searchable: true + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: entries + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entries + field: event_id + type: uuid + meta: + collection: entries + conditions: null + display: null + display_options: null + field: event_id + group: null + hidden: false + interface: select-dropdown-m2o + note: Event this entry races in + options: + template: '{{name}}' + readonly: false + required: true + searchable: true + sort: 2 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: event_id + table: entries + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: events + foreign_key_column: id + - collection: entries + field: vehicle_id + type: uuid + meta: + collection: entries + conditions: null + display: null + display_options: null + field: vehicle_id + group: null + hidden: false + interface: select-dropdown-m2o + note: 'Nullable: null for foot races (trail-run / hike disciplines)' + options: + template: '{{make}} {{model}}' + readonly: false + required: false + searchable: true + sort: 3 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: vehicle_id + table: entries + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: vehicles + foreign_key_column: id + - collection: entries + field: class_id + type: uuid + meta: + collection: entries + conditions: null + display: null + display_options: null + field: class_id + group: null + hidden: false + interface: select-dropdown-m2o + note: Class this entry competes in + options: + template: '{{code}} โ€” {{name}}' + readonly: false + required: true + searchable: true + sort: 4 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: class_id + table: entries + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: classes + foreign_key_column: id + - collection: entries + field: race_number + type: integer + meta: + collection: entries + conditions: null + display: null + display_options: null + field: race_number + group: null + hidden: false + interface: input + note: >- + Per Rally Albania ยง5: 1โ€“199 moto, 2xx quad, 3xx car, 4xx SSV. Unique + within event (composite unique via db-init/005). + options: null + readonly: false + required: true + searchable: true + sort: 5 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: race_number + table: entries + data_type: integer + default_value: null + max_length: null + numeric_precision: 32 + numeric_scale: 0 + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entries + field: status + type: string + meta: + collection: entries + conditions: null + display: null + display_options: null + field: status + group: null + hidden: false + interface: select-dropdown + note: >- + Lifecycle: registered โ†’ confirmed โ†’ started โ†’ finished. + dnf/dns/dq/withdrawn are terminal failure states. + options: + choices: + - text: Registered + value: registered + - text: Confirmed + value: confirmed + - text: Started + value: started + - text: Finished + value: finished + - text: DNF (Did Not Finish) + value: dnf + - text: DNS (Did Not Start) + value: dns + - text: DQ (Disqualified) + value: dq + - text: Withdrawn + value: withdrawn + readonly: false + required: true + searchable: true + sort: 6 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: status + table: entries + data_type: character varying + default_value: registered + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entries + field: registered_at + type: timestamp + meta: + collection: entries + conditions: null + display: null + display_options: null + field: registered_at + group: null + hidden: false + interface: datetime + note: When the entry was registered + options: null + readonly: false + required: false + searchable: true + sort: 7 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: registered_at + table: entries + data_type: timestamp with time zone + default_value: CURRENT_TIMESTAMP + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entries + field: notes + type: text + meta: + collection: entries + conditions: null + display: null + display_options: null + field: notes + group: null + hidden: false + interface: input-multiline + note: Free-form notes + options: null + readonly: false + required: false + searchable: true + sort: 8 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: notes + table: entries + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entries + field: date_created + type: timestamp + meta: + collection: entries + conditions: null + display: null + display_options: null + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 9 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: entries + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entries + field: date_updated + type: timestamp + meta: + collection: entries + conditions: null + display: null + display_options: null + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 10 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: entries + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_crew + field: id + type: uuid + meta: + collection: entry_crew + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + searchable: true + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: entry_crew + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_crew + field: entry_id + type: uuid + meta: + collection: entry_crew + conditions: null + display: null + display_options: null + field: entry_id + group: null + hidden: false + interface: select-dropdown-m2o + note: null + options: null + readonly: false + required: true + searchable: true + sort: 2 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: entry_id + table: entry_crew + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: entries + foreign_key_column: id + - collection: entry_crew + field: user_id + type: uuid + meta: + collection: entry_crew + conditions: null + display: null + display_options: null + field: user_id + group: null + hidden: false + interface: select-dropdown-m2o + note: null + options: + template: '{{first_name}} {{last_name}} ({{email}})' + readonly: false + required: true + searchable: true + sort: 3 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: user_id + table: entry_crew + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: entry_crew + field: role + type: string + meta: + collection: entry_crew + conditions: null + display: null + display_options: null + field: role + group: null + hidden: false + interface: select-dropdown + note: >- + On-track racing role (distinct from organization_users.role which is a + permissions concept) + options: + choices: + - text: Pilot + value: pilot + - text: Co-Pilot + value: co-pilot + - text: Navigator + value: navigator + - text: Mechanic + value: mechanic + - text: Rider + value: rider + - text: Runner + value: runner + - text: Hiker + value: hiker + readonly: false + required: true + searchable: true + sort: 4 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: role + table: entry_crew + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_crew + field: date_created + type: timestamp + meta: + collection: entry_crew + conditions: null + display: null + display_options: null + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 5 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: entry_crew + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_crew + field: date_updated + type: timestamp + meta: + collection: entry_crew + conditions: null + display: null + display_options: null + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 6 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: entry_crew + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_devices + field: id + type: uuid + meta: + collection: entry_devices + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + searchable: true + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: entry_devices + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_devices + field: entry_id + type: uuid + meta: + collection: entry_devices + conditions: null + display: null + display_options: null + field: entry_id + group: null + hidden: false + interface: select-dropdown-m2o + note: null + options: null + readonly: false + required: true + searchable: true + sort: 2 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: entry_id + table: entry_devices + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: entries + foreign_key_column: id + - collection: entry_devices + field: device_id + type: uuid + meta: + collection: entry_devices + conditions: null + display: null + display_options: null + field: device_id + group: null + hidden: false + interface: select-dropdown-m2o + note: null + options: + template: '{{model}} {{imei}}' + readonly: false + required: true + searchable: true + sort: 3 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: device_id + table: entry_devices + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: devices + foreign_key_column: id + - collection: entry_devices + field: assigned_user_id + type: uuid + meta: + collection: entry_devices + conditions: null + display: null + display_options: null + field: assigned_user_id + group: null + hidden: false + interface: select-dropdown-m2o + note: >- + Nullable: null = vehicle-mounted (hardwired or backup); set = body-worn + on this crew member (panic button) + options: + template: '{{first_name}} {{last_name}}' + readonly: false + required: false + searchable: true + sort: 4 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: assigned_user_id + table: entry_devices + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id + - collection: entry_devices + field: mount_position + type: string + meta: + collection: entry_devices + conditions: null + display: null + display_options: null + field: mount_position + group: null + hidden: false + interface: input + note: >- + Free-form mount label (e.g. "panic_button_pilot", "hardwired_dash", + "backup_chassis") + options: null + readonly: false + required: false + searchable: true + sort: 5 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: mount_position + table: entry_devices + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_devices + field: date_created + type: timestamp + meta: + collection: entry_devices + conditions: null + display: null + display_options: null + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 6 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: entry_devices + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: entry_devices + field: date_updated + type: timestamp + meta: + collection: entry_devices + conditions: null + display: null + display_options: null + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 7 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: entry_devices + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: id + type: uuid + meta: + collection: events + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + searchable: true + sort: 1 + special: + - uuid + translations: null + validation: null + validation_message: null + width: full + schema: + name: id + table: events + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_indexed: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: organization_id + type: uuid + meta: + collection: events + conditions: null + display: null + display_options: null + field: organization_id + group: null + hidden: false + interface: select-dropdown-m2o + note: Owning org. An event belongs to exactly one org. + options: + template: '{{name}}' + readonly: false + required: true + searchable: true + sort: 2 + special: + - m2o + translations: null + validation: null + validation_message: null + width: half + schema: + name: organization_id + table: events + data_type: uuid + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: organizations + foreign_key_column: id + - collection: events + field: name + type: string + meta: + collection: events + conditions: null + display: null + display_options: null + field: name + group: null + hidden: false + interface: input + note: Display name (e.g. "Rally Albania 2026") + options: null + readonly: false + required: true + searchable: true + sort: 3 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: name + table: events + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: slug + type: string + meta: + collection: events + conditions: null + display: null + display_options: null + field: slug + group: null + hidden: false + interface: input + note: >- + URL-friendly identifier, unique within the org (composite unique + constraint enforced via db-init/005) + options: + slug: true + trim: true + readonly: false + required: true + searchable: true + sort: 4 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: slug + table: events + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: discipline + type: string + meta: + collection: events + conditions: null + display: null + display_options: null + field: discipline + group: null + hidden: false + interface: select-dropdown + note: 'Drives validation: rally requires vehicle, trail-run/hike does not, etc.' + options: + choices: + - text: Rally + value: rally + - text: Time Trial + value: time-trial + - text: Regatta + value: regatta + - text: Trail Run + value: trail-run + - text: Hike + value: hike + readonly: false + required: true + searchable: true + sort: 5 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: discipline + table: events + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: starts_at + type: timestamp + meta: + collection: events + conditions: null + display: null + display_options: null + field: starts_at + group: null + hidden: false + interface: datetime + note: Event window begin + options: null + readonly: false + required: true + searchable: true + sort: 6 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: starts_at + table: events + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: ends_at + type: timestamp + meta: + collection: events + conditions: null + display: null + display_options: null + field: ends_at + group: null + hidden: false + interface: datetime + note: Event window end + options: null + readonly: false + required: true + searchable: true + sort: 7 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: ends_at + table: events + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: regulation_doc_url + type: string + meta: + collection: events + conditions: null + display: null + display_options: null + field: regulation_doc_url + group: null + hidden: false + interface: input + note: External URL to the rulebook PDF/page + options: null + readonly: false + required: false + searchable: true + sort: 8 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: regulation_doc_url + table: events + data_type: character varying + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: notes + type: text + meta: + collection: events + conditions: null + display: null + display_options: null + field: notes + group: null + hidden: false + interface: input-multiline + note: Free-form notes + options: null + readonly: false + required: false + searchable: true + sort: 9 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: notes + table: events + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: date_created + type: timestamp + meta: + collection: events + conditions: null + display: null + display_options: null + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 10 + special: + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: events + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: events + field: date_updated + type: timestamp + meta: + collection: events + conditions: null + display: null + display_options: null + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + searchable: true + sort: 11 + special: + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: events + data_type: timestamp with time zone + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_indexed: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null - collection: organization_devices field: id type: uuid @@ -2031,6 +3933,216 @@ systemFields: schema: is_indexed: true relations: + - collection: classes + field: event_id + related_collection: events + meta: + junction_field: null + many_collection: classes + many_field: event_id + one_allowed_collections: null + one_collection: events + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: classes + column: event_id + foreign_key_table: events + foreign_key_column: id + constraint_name: classes_event_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entries + field: event_id + related_collection: events + meta: + junction_field: null + many_collection: entries + many_field: event_id + one_allowed_collections: null + one_collection: events + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entries + column: event_id + foreign_key_table: events + foreign_key_column: id + constraint_name: entries_event_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entries + field: vehicle_id + related_collection: vehicles + meta: + junction_field: null + many_collection: entries + many_field: vehicle_id + one_allowed_collections: null + one_collection: vehicles + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entries + column: vehicle_id + foreign_key_table: vehicles + foreign_key_column: id + constraint_name: entries_vehicle_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entries + field: class_id + related_collection: classes + meta: + junction_field: null + many_collection: entries + many_field: class_id + one_allowed_collections: null + one_collection: classes + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entries + column: class_id + foreign_key_table: classes + foreign_key_column: id + constraint_name: entries_class_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entry_crew + field: entry_id + related_collection: entries + meta: + junction_field: null + many_collection: entry_crew + many_field: entry_id + one_allowed_collections: null + one_collection: entries + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entry_crew + column: entry_id + foreign_key_table: entries + foreign_key_column: id + constraint_name: entry_crew_entry_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entry_crew + field: user_id + related_collection: directus_users + meta: + junction_field: null + many_collection: entry_crew + many_field: user_id + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entry_crew + column: user_id + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: entry_crew_user_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entry_devices + field: entry_id + related_collection: entries + meta: + junction_field: null + many_collection: entry_devices + many_field: entry_id + one_allowed_collections: null + one_collection: entries + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entry_devices + column: entry_id + foreign_key_table: entries + foreign_key_column: id + constraint_name: entry_devices_entry_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entry_devices + field: device_id + related_collection: devices + meta: + junction_field: null + many_collection: entry_devices + many_field: device_id + one_allowed_collections: null + one_collection: devices + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entry_devices + column: device_id + foreign_key_table: devices + foreign_key_column: id + constraint_name: entry_devices_device_id_foreign + on_update: NO ACTION + on_delete: RESTRICT + - collection: entry_devices + field: assigned_user_id + related_collection: directus_users + meta: + junction_field: null + many_collection: entry_devices + many_field: assigned_user_id + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: entry_devices + column: assigned_user_id + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: entry_devices_assigned_user_id_foreign + on_update: NO ACTION + on_delete: SET NULL + - collection: events + field: organization_id + related_collection: organizations + meta: + junction_field: null + many_collection: events + many_field: organization_id + one_allowed_collections: null + one_collection: organizations + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: events + column: organization_id + foreign_key_table: organizations + foreign_key_column: id + constraint_name: events_organization_id_foreign + on_update: NO ACTION + on_delete: RESTRICT - collection: organization_devices field: organization_id related_collection: organizations