diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index fe102b95..e727e113 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1150,162 +1150,265 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; ## 3.5.7 Artists & Advancing -### `artists` +> **Authoritative spec:** RFC-TIMETABLE v0.2 §5.3. The pre-v0.2 schema +> (event-scoped `artists`, milestone bool flags, `artist_riders`, +> `itinerary_items`) was replaced in 2026-05 — see +> `dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md` for the design +> decisions (D9, D10, D13, D14, D17, D23, D24, D26). -| Column | Type | Notes | -| ------------------------------ | ------------------ | -------------------------------------------------------------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `name` | string | | -| `booking_status` | enum | `concept\|requested\|option\|confirmed\|contracted\|cancelled` | -| `star_rating` | tinyint | 1–5 | -| `project_leader_id` | ULID FK nullable | → users | -| `milestone_offer_in` | bool | Default: false | -| `milestone_offer_agreed` | bool | Default: false | -| `milestone_confirmed` | bool | Default: false | -| `milestone_announced` | bool | Default: false | -| `milestone_schedule_confirmed` | bool | Default: false | -| `milestone_itinerary_sent` | bool | Default: false | -| `milestone_advance_sent` | bool | Default: false | -| `milestone_advance_received` | bool | Default: false | -| `advance_open_from` | datetime nullable | | -| `advance_open_to` | datetime nullable | | -| `show_advance_share_page` | bool | Default: true | -| `portal_token` | ULID unique | Access to artist portal without account | -| `deleted_at` | timestamp nullable | Soft delete | +The artist domain splits into a **master record** (`artists`, +org-scoped) and a **per-event booking** (`artist_engagements`). +The split allows one artist to have multiple engagements across +events, each with its own deal, advance trajectory, and timetable +performances. -**Relations:** `hasMany` performances, advance_sections, artist_contacts, artist_riders -**Soft delete:** yes +### `genres` + +| Column | Type | Notes | +| ----------------- | ------- | -------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations | +| `name` | string(40) | | +| `color` | string(7) nullable | hex | +| `sort_order` | int default 0 | | +| `is_active` | bool default true | use instead of soft delete | +| `created_at`, `updated_at` | timestamps | | + +**Unique:** `UNIQUE(organisation_id, name)` +**Soft delete:** no — `is_active=false` is the retire mechanism (D24) +**Scope:** `OrganisationScope` (direct `organisation_id`) --- -### `performances` +### `artists` (master) -| Column | Type | Notes | -| ----------------- | ------- | ------------------------------- | -| `id` | ULID | PK | -| `artist_id` | ULID FK | → artists | -| `stage_id` | ULID FK | → stages | -| `date` | date | | -| `start_time` | time | | -| `end_time` | time | | -| `booking_status` | string | | -| `check_in_status` | enum | `expected\|checked_in\|no_show` | +| Column | Type | Notes | +| ------------------- | ---------------- | ------------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations | +| `name` | string(120) | | +| `slug` | string(120) | unique per organisation, generated from name | +| `default_genre_id` | ULID FK nullable | → genres (D24) | +| `default_draw` | int nullable | for capacity-warn defaults | +| `star_rating` | tinyint nullable | 1–5 | +| `home_base_country` | string(2) nullable | ISO 3166-1 alpha-2 | +| `agent_company_id` | ULID FK nullable | → companies (typically `type=agency`) | +| `notes` | text nullable | | +| `created_at`, `updated_at`, `deleted_at` | | soft delete | -**Indexes:** `(stage_id, date, start_time, end_time)` +**Unique:** `UNIQUE(organisation_id, slug)` +**Indexes:** `(organisation_id, name)`, `(default_genre_id)`, `(agent_company_id)` +**Soft delete:** yes (RFC §5.4) +**Scope:** `OrganisationScope` (direct `organisation_id`) + +--- + +### `companies.handles_buma` (added column) + +The existing `companies` table (§3.5.5) gains a single bool column: + +| Column | Type | Notes | +| -------------- | ---------------- | -------------------------------------- | +| `handles_buma` | bool default false | Whether an agency reports BUMA on the artist's behalf (RFC D26) | + +The existing `type` enum is unchanged — the `agency` value is reused +for booking agencies in the artist domain. + +--- + +### `artist_contacts` (master-scoped) + +| Column | Type | Notes | +| -------------------- | ---------------- | ------------------------------ | +| `id` | ULID | PK | +| `artist_id` | ULID FK | → artists (master) | +| `name` | string(120) | | +| `email` | string nullable | | +| `phone` | string nullable | | +| `role` | string(60) | tour_manager, agent, manager… | +| `is_primary` | bool default false | | +| `receives_briefing` | bool default false | | +| `receives_infosheet` | bool default false | | +| `created_at`, `updated_at` | timestamps | | + +**Indexes:** `(artist_id, role)` +**Soft delete:** no +**Scope:** `OrganisationScope` (FK-chain via `artist_id → artists.organisation_id`) --- ### `stages` -| Column | Type | Notes | -| ---------- | ------------ | -------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `name` | string | | -| `color` | string | hex | -| `capacity` | int nullable | | +| Column | Type | Notes | +| ------------ | ------------ | -------------------------------- | +| `id` | ULID | PK | +| `event_id` | ULID FK | → events (festival or flat event) | +| `name` | string(120) | | +| `color` | string(7) | hex | +| `capacity` | int nullable | drives capacity-warn (RFC D23) | +| `sort_order` | int default 0 | (RFC D23) | +| `created_at`, `updated_at` | timestamps | | -**Relations:** `hasMany` performances -**Indexes:** `(event_id)` +**Unique:** `UNIQUE(event_id, name)` +**Indexes:** `(event_id, sort_order)` +**Soft delete:** no — destructive deletion is rare; query complexity isn't worth the safety win +**Scope:** `OrganisationScope` (FK-chain via `event_id → events.organisation_id`) --- ### `stage_days` -| Column | Type | Notes | -| ---------- | ------- | --------------------------------- | -| `id` | int AI | PK — integer for join performance | -| `stage_id` | ULID FK | → stages | -| `day_date` | date | | +| Column | Type | Notes | +| ----------- | ------- | ---------------------------------------------- | +| `id` | int AI | PK — integer for join performance | +| `stage_id` | ULID FK | → stages | +| `event_id` | ULID FK | → events (sub-event or flat event = "show host") | -**Unique constraint:** `UNIQUE(stage_id, day_date)` +**Unique:** `UNIQUE(stage_id, event_id)` +**Indexes:** `(event_id)` +**Soft delete:** no (pure pivot) +**Scope:** `OrganisationScope` (FK-chain via stage) + +For a festival, each stage has multiple `stage_days` rows (one per +active sub-event). For a flat event, each stage has exactly one +row referencing the event itself. --- -### `advance_sections` +### `artist_engagements` (per-event booking) -| Column | Type | Notes | -| ------------------- | ------------------ | -------------------------------------------------------------- | -| `id` | ULID | PK | -| `artist_id` | ULID FK | → artists | -| `name` | string | | -| `type` | enum | `guest_list\|contacts\|production\|custom` | -| `is_open` | bool | | -| `open_from` | datetime nullable | | -| `open_to` | datetime nullable | | -| `sort_order` | int | | -| `submission_status` | enum | `open\|pending\|submitted\|approved\|declined` | -| `last_submitted_at` | timestamp nullable | | -| `last_submitted_by` | string nullable | | -| `submission_diff` | JSON nullable | `{created, updated, untouched, deleted}` counts per submission | +| Column | Type | Notes | +| ---------------------------- | -------------------------- | ---------------------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations (denormalised — observer-maintained) | +| `artist_id` | ULID FK | → artists (master) | +| `event_id` | ULID FK | → events (festival or flat event) | +| `booking_status` | string | RFC D9 — see `App\Enums\Artist\ArtistEngagementStatus` | +| `project_leader_id` | ULID FK nullable | → users | +| `fee_amount` | decimal(10,2) nullable | | +| `fee_currency` | string(3) default 'EUR' | | +| `fee_type` | string nullable | `App\Enums\Artist\FeeType` | +| `buma_applicable` | bool default true | | +| `buma_percentage` | decimal(5,2) default 7.00 | | +| `buma_handled_by` | string default 'organisation' | `App\Enums\Artist\BumaHandledBy` (D26) | +| `vat_applicable` | bool default true | | +| `vat_percentage` | decimal(5,2) default 21.00 | | +| `deal_breakdown` | JSON nullable | opaque line-items | +| `deposit_percentage` | decimal(5,2) nullable | | +| `deposit_due_date` | date nullable | | +| `balance_due_date` | date nullable | | +| `payment_status` | string default 'none' | `App\Enums\Artist\PaymentStatus` | +| `crew_count` | int default 0 | | +| `guests_count` | int default 0 | | +| `requested_at` | datetime nullable | set when status → Requested | +| `option_expires_at` | datetime nullable | required when status=Option; demote-job uses this | +| `advance_open_from` | datetime nullable | | +| `advance_open_to` | datetime nullable | | +| `portal_token` | ULID unique nullable | tour-manager portal access | +| `advancing_completed_count` | int default 0 | observer-maintained (Session 3) | +| `advancing_total_count` | int default 0 | observer-maintained (Session 3) | +| `notes` | text nullable | | +| `created_at`, `updated_at`, `deleted_at` | | soft delete | -**Indexes:** `(artist_id, is_open)`, `(artist_id, submission_status)` +**Unique:** `UNIQUE(artist_id, event_id)`, `UNIQUE(portal_token)` +**Indexes:** `(organisation_id)`, `(event_id, booking_status)`, `(option_expires_at)` +**Soft delete:** yes (cascades to performances, advance_sections via `ArtistEngagementObserver`) +**Scope:** `OrganisationScope` (direct `organisation_id`) +**Observers:** +- `creating`: auto-fills `organisation_id` from `artist`, asserts `artist.organisation_id === event.organisation_id` (cross-tenant guard — `CrossTenantEngagementException`) +- `deleted`: cascade soft-deletes `performances`, hard-deletes `advance_sections`. `advance_submissions` rows are immutable audit records and remain attached. + +--- + +### `performances` + +| Column | Type | Notes | +| --------------- | -------------------------- | ---------------------------------------------------- | +| `id` | ULID | PK | +| `engagement_id` | ULID FK | → artist_engagements | +| `event_id` | ULID FK | → events (sub-event or flat event = "show host") | +| `stage_id` | ULID FK nullable | → stages; NULL = parked / wachtrij (D13) | +| `lane` | unsigned tinyint default 0 | (D13) | +| `start_at` | datetime | ⩾ event.start, < event.end | +| `end_at` | datetime | > start_at, ⩽ event.end | +| `version` | int default 0 | optimistic-lock counter — observer-maintained (D14) | +| `notes` | text nullable | | +| `created_at`, `updated_at`, `deleted_at` | | soft delete | + +**Indexes:** `(event_id, stage_id, start_at, end_at)`, `(engagement_id)`, `(stage_id, start_at)` (lane resolver) +**Soft delete:** yes (cascade with engagement via observer) +**Scope:** `OrganisationScope` (FK-chain via `engagement_id → artist_engagements.organisation_id`) +**Observer (`PerformanceObserver`):** increments `version` by 1 on every UPDATE (D14 optimistic lock — `MoveTimetablePerformanceRequest` in Session 2 compares against client-supplied version). + +--- + +### `advance_sections` (engagement-scoped) + +| Column | Type | Notes | +| ------------------- | ------------------- | -------------------------------------------------------------- | +| `id` | ULID | PK | +| `engagement_id` | ULID FK | → artist_engagements (was `artist_id` in the pre-v0.2 plan) | +| `name` | string(80) | | +| `type` | string | `App\Enums\Artist\AdvanceSectionType` | +| `is_open` | bool default false | | +| `open_from` | datetime nullable | | +| `open_to` | datetime nullable | | +| `sort_order` | int default 0 | | +| `submission_status` | string default 'open' | `App\Enums\Artist\AdvanceSectionSubmissionStatus` | +| `last_submitted_at` | timestamp nullable | | +| `last_submitted_by` | string nullable | tour-manager name from form | +| `submission_diff` | JSON nullable | `{created, updated, untouched, deleted}` counts per submission | +| `created_at`, `updated_at` | timestamps | | + +**Indexes:** `(engagement_id, is_open)`, `(engagement_id, submission_status)` +**Soft delete:** no — hard-deleted with the parent engagement (RFC §5.4) +**Scope:** `OrganisationScope` (FK-chain via engagement) + +The shift from `artist_id` (master) to `engagement_id` (per-event) is +the schema correction that allows multi-event artists to advance +each engagement independently. The `form_submission` subject remains +the master `Artist` (per `PurposeRegistry` §17.3) — see +`ARCH-FORM-BUILDER.md §3.2.5` for the resolver wiring. --- ### `advance_submissions` -| Column | Type | Notes | -| -------------------- | ------------------ | ------------------------------ | -| `id` | ULID | PK | -| `advance_section_id` | ULID FK | → advance_sections | -| `submitted_by_name` | string | | -| `submitted_by_email` | string | | -| `submitted_at` | timestamp | | -| `status` | enum | `pending\|accepted\|declined` | -| `reviewed_by` | ULID FK nullable | → users | -| `reviewed_at` | timestamp nullable | | -| `data` | JSON | Free form data — not queryable | +| Column | Type | Notes | +| -------------------- | --------------------- | ------------------------------ | +| `id` | ULID | PK | +| `advance_section_id` | ULID FK | → advance_sections | +| `submitted_by_name` | string | | +| `submitted_by_email` | string | | +| `submitted_at` | timestamp | | +| `status` | string default 'pending' | `App\Enums\Artist\AdvanceSubmissionStatus` | +| `reviewed_by` | ULID FK nullable | → users | +| `reviewed_at` | timestamp nullable | | +| `data` | JSON | free-form payload — not queryable | +| `created_at`, `updated_at` | timestamps | | **Indexes:** `(advance_section_id, status)` +**Soft delete:** no — audit-immutable (RFC §5.4). Survive engagement deletion via FK; no application code mutates these rows after creation. +**Scope:** `OrganisationScope` (FK-chain via section → engagement) --- -### `artist_contacts` +### Enums (under `App\Enums\Artist\`) -| Column | Type | Notes | -| -------------------- | --------------- | -------------------------------- | -| `id` | ULID | PK | -| `artist_id` | ULID FK | → artists | -| `name` | string | | -| `email` | string nullable | | -| `phone` | string nullable | | -| `role` | string | e.g. tour manager, agent, booker | -| `receives_briefing` | bool | | -| `receives_infosheet` | bool | | -| `is_travel_party` | bool | | +| Enum | Values | Notes | +| --------------------------------- | ----------------------------------------------------------------------------------------- | ----- | +| `ArtistEngagementStatus` | `draft`, `requested`, `option`, `offered`, `confirmed`, `contracted`, `cancelled`, `rejected`, `declined` | RFC D9 — Dutch labels via `label()` | +| `BumaHandledBy` | `organisation`, `booking_agency`, `not_applicable` | RFC D26 | +| `FeeType` | `flat`, `door_split`, `guarantee_plus_split` | | +| `PaymentStatus` | `none`, `deposit_paid`, `paid_in_full` | | +| `AdvanceSectionType` | `guest_list`, `contacts`, `production`, `custom` | | +| `AdvanceSectionSubmissionStatus` | `open`, `pending`, `submitted`, `approved`, `declined` | | +| `AdvanceSubmissionStatus` | `pending`, `accepted`, `declined` | | -**Indexes:** `(artist_id)` - ---- - -### `artist_riders` - -| Column | Type | Notes | -| ----------- | ------- | ------------------------ | -| `id` | ULID | PK | -| `artist_id` | ULID FK | → artists | -| `category` | enum | `technical\|hospitality` | -| `items` | JSON | Unstructured rider data | - -**Indexes:** `(artist_id, category)` - ---- - -### `itinerary_items` - -| Column | Type | Notes | -| --------------- | --------------- | -------------------------------------------------- | -| `id` | ULID | PK | -| `artist_id` | ULID FK | → artists | -| `type` | enum | `transfer\|pickup\|delivery\|checkin\|performance` | -| `datetime` | datetime | | -| `from_location` | string nullable | | -| `to_location` | string nullable | | -| `notes` | text nullable | | - -**Indexes:** `(artist_id, datetime)` +> **Riders + itineraries:** the previous §3.5.7 plan included +> `artist_riders` and `itinerary_items` tables. RFC v0.2 §5.3 does +> NOT create them; rider data lives in advance-section submissions +> (free-form JSON), and itineraries are deferred to a future RFC. ---