Files
crewli/dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md

1655 lines
70 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.
# RFC-TIMETABLE — Artist Timetable & Engagement Module
## 1. Status
- **State:** Draft for review
- **Created:** 2026-05-08
- **Version:** v0.2
- **Owner:** Bert Hausmans
- **Replaces:** v0.1 (2026-05-08)
- **Origin:** v0.1 + brainstorm session 2026-05-08 (Claude Chat) +
prototype audit (commit `a57437a`, `dev-docs/audits/PROTOTYPE-AUDIT-
ARTIST-TIMETABLE.md`) + decisions Bert 2026-05-08
- **Related:**
- `SCHEMA.md` §3.5.7 (will be substantially rewritten by this RFC)
- `ARCH-PLANNED-MODULES.md` §3.5.7 (will move to SCHEMA.md as it lands)
- `BACKLOG.md` ARCH-09 (resolved by Session 1), ART-03 (absorbed),
new ART-04…ART-13, FIN-01, FIN-02
- `ARCH-FORM-BUILDER.md` §3.2.5 (`artist_advance` purpose), §17.3
(purpose registry), §4 (form_schemas), §6 (binding semantics)
- `AUTH_ARCHITECTURE.md` §6 (portal-token flow)
- `UX_SPEC_FESTIVAL_HIERARCHY.md` (festival/sub-event invariants)
- `CLAUDE.md` "Order of work"
- `VUEXY_COMPONENTS.md` (frontend conventions)
## 2. Why this RFC exists
The Artist Timetable is the central planning surface for festival
programming. It is operationally critical: production managers spend
hours per week on it, last-minute changes are common, a bug here
visibly breaks the show, and bookers will not adopt a tool that
mishandles the deal-info layer (Buma, holds, contracts).
Five properties make this module non-trivial and warrant up-front spec:
1. It depends on a model (Artist) that does not yet exist (ARCH-09).
2. It introduces a new architectural layer — `ArtistEngagement`
that did not exist in v0.1: artists are now org-level, engagements
are per-event. This is a deliberate departure from SCHEMA §3.5.7's
single-artists-per-event model and resolves backlog ART-03.
3. It bridges concerns festival operators conflate but Crewli must
keep distinct: artists (master), engagements (per-event), stages
(physical), sub-events (program units), advancing (per-engagement
workflow), and Form Builder (the rider/advance data layer).
4. It introduces interaction patterns (drag-drop Gantt with
server-transactional cascade-bump, cross-day stage variance,
popover with advancing aggregate, keyboard-accessible blocks)
that do not exist off-the-shelf in the current frontend stack.
5. The booker-experience features (Buma handling, option expiry,
crew & guestlist contingent, fee deal info) are not optional
polish — they are what make the module usable at festival scale.
v0.1 underscoped these.
This RFC captures every architectural decision so implementation
does not re-litigate them under time pressure. The PoC iterations
(prototype at `./resources/Crewli - Artist Timetable Management/`)
are visual references — this document is authoritative.
## 3. Scope & non-scope
### 3.1 In scope (v1)
**Backend (Sessions 13):**
- Org-level `artists` table + `artist_engagements` per-event table
- Org-level `genres` configurable list
- `stages` (festival-level or flat-event-level), `stage_days` (pivot
to sub-events or flat event), `performances` (sub-event-scoped)
- 9-status enum on engagements (incl. `rejected` vs `declined`)
- Buma + VAT + deal info (fee, deposit, payment status, crew/guests)
- Option-expiry mechanism (scheduled job)
- Form Builder integration via `artist_advance` purpose, section-level
submit, portal_token per engagement
- Server-side transactional cascade-bump endpoint
- Server-side lane resolution on read
- Optimistic locking via `version` column
- Conflict detection (same-stage, same-day, same-lane), B2B detection
(≤5 min gap), capacity warning (`draw > capacity * 1.1`)
- Wachtrij = nullable `stage_id` filter (no separate table)
- Activity log on all mutations
- Multi-tenancy via `OrganisationScope` (FK-chain)
**Frontend (Sessions 46):**
- Horizontal Gantt timetable with day-tabs (one tab per sub-event,
no tabs for flat events)
- Drag/drop performance: re-time, re-stage, re-lane in one gesture
- Resize duration (snap 15 min, min 15 min)
- Click-to-add performance (modal) and drag-to-create (inline ghost)
- Detail popover with advancing aggregate, status switch, action buttons
- Wachtrij sidebar with search, status multi-select, genre filter
- "Stages per dag" matrix bulk editor
- Stage editor (single-stage CRUD with cascade-to-wachtrij on delete)
- Stage reorder (drag stage rows)
- Empty-day copy-from-other-day affordance
- Engagement detail page (`/events/{event}/artists/{engagement}`)
with tabs: Overview, Performances, Advancing, Contacts, Crew &
Guests, Activity log
- Portal page (`/p/artist/{token}`) for tour manager
- **Master Artist Library** (`/organisation/artists`) — list with
search, filter, sort, pagination, "Toon verwijderde" toggle
- **Master Artist Detail** (`/organisation/artists/{artist}`) — tabs:
Overview (master fields), Engagements (cross-event history),
Contacts (master CRUD), Activity log
- Trashed-artist visual markers on all engagement / timetable surfaces (D27)
- Keyboard a11y model (focusable blocks, arrow nudges, etc.)
- CSS-tokens for status colours (theme-overridable)
- Optimistic updates with version-check rollback + toast
### 3.2 Out of scope (v1) — backlog items
| ID | Item | Notes |
|---|---|---|
| ART-04 | Tech-rider warnings vs stage-spec | Requires parsed-rider fields + stage tech-spec columns |
| ART-05 | PDF/print A3 running order | |
| ART-06 | Bulk shift on canvas (multi-select +30 min) | |
| ART-07 | Undo/redo stack | |
| ART-08 | Cross-stage artist double-booking warning | Surfaces on artist detail, not timetable |
| ART-10 | Venue-as-row variant (cultuurnacht model) | Schema accommodates this without changes |
| ART-11 | Mission Control artist check-in | Separate module on show-day |
| ART-12 | Counter-offers / negotiation log | |
| ART-13 | Routing intelligence (home-base clustering) | |
| ART-14 | Per-tenant status colour overrides | CSS-tokens enable this; activation deferred |
| ART-15 | Realtime broadcasts (Reverb) | Wait until notification framework |
| ART-16 | Hospitality auto-aggregation dashboard | Per-day catering totals |
| ART-17 | Mobile-optimised view | Read-only list on mobile |
| ART-18 | Stage templates across events | Parallel to ARCH-10 sectie templates |
| ART-19 | Public reveal scheduling (line-up phasing) | |
| ART-20 | A1 / work permit tracking for international acts | |
| ART-21 | Cost-control dashboard | Total fee budget vs draw projection |
| ART-22 | Cross-edition analytics | "Mau P 2025 vs 2026 metrics" |
| ART-23 | Mailbox-thread per booking | Agent correspondence in-app |
| ART-24 | GDPR hard-erasure flow voor artist + contacten | Alleen op explicit data subject request; runbook-driven |
| FIN-01 | Buma export per festival (CSV) | |
| FIN-02 | Buma forecast widget on org dashboard | |
## 4. Locked design decisions
26 decisions, organised into **architecture** (D10D17, D22),
**interaction** (D1, D5, D7, D13, D18, D20), **schema details**
(D9, D23, D24, D26), **integration** (D2, D3, D11, D15), **rendering**
(D6, D8, D21, D25), and **technical contracts** (D4, D14, D16, D19).
### D1 — Block visual: stage-stripe + status-fill
Each performance block carries:
- Background fill = engagement.booking_status colour (9 colours, see §10.1)
- 3 px left stripe = `stages.color` for stage-grouping cue
- Right grab handle for resize
Rationale unchanged from v0.1.
### D2 — Advancing aggregate on `artist_engagement`, not `artist`
**Revised from v0.1.** The aggregate
`{advancing_completed_count, advancing_total_count}` is denormalised
on `artist_engagements`, not on `artists`. An artist can have multiple
engagements (org-level master); advancing is per-event.
Computed via observer on `advance_section` create/update/delete:
```
n = count(advance_sections WHERE engagement_id = X
AND submission_status = 'approved')
m = count(advance_sections WHERE engagement_id = X)
```
Stored as `advancing_completed_count` + `advancing_total_count` int
columns on `artist_engagements`. Avoids N+1 on timetable load.
### D3 — Manage booking opens engagement page (not artist master)
**Revised from v0.1.** Click "Open detailpagina →" in popover →
navigate to `/events/{event}/artists/{engagement}?return=timetable
&day=fri&t=210000`.
The page shows event-specific data: deal info + Buma, performances
within this event, advancing sections + status, contacts, crew &
guestlist, activity log. A small "→ Master profile" link in the
header goes to `/organisation/artists/{artist}` for cross-event view
(power-user only, not v1 daily driver).
The URL path uses `/artists/` for SEO-friendly naming; the parameter
itself is the engagement ULID. Bookmarkability + cmd-click-to-new-tab
preserved as in v0.1.
### D4 — Stage-day filtering enforced everywhere
Performance for sub-event D shows only stages where `stage_days
(stage_id, event_id=D)` exists. Performances scheduled on
`(stage_id, sub_event_id=D)` where the pivot does not exist are
**hidden but not deleted** — toggling a stage off must be reversible
without data loss.
Enforced at: API list endpoint (SQL filter), frontend rendering
(defensive trust-but-verify), FormRequest on create
(`StageActiveOnSubEvent` rule rejects with 422).
### D5 — Conflict detection scope: same-stage, same-day, same-lane
**Refined.** Two non-cancelled performances with the same
`stage_id`, same `sub_event_id`, same `lane`, and overlapping time
(`p1.end_at > p2.start_at AND p1.start_at < p2.end_at`) → conflict.
- Different lanes on same stage = NO conflict (lane semantics permit
parallel acts; e.g. one DJ + one MC)
- Different stages = NO conflict (cross-stage warning sits on artist
detail page per ART-08)
- Cancelled performances participate in NO conflict check
- Conflict produces a 200 response with `warnings: ["overlap"]`
the booker decides whether to resolve
Warning rendering: red border + warn icon + count badge on stage row +
total in header pill + footer stat (per prototype audit §4.8).
### D6 — B2B marker rule
Two consecutive non-cancelled performances on the same stage with
`p2.start_at - p1.end_at ∈ [0, 5]` minutes get a B2B marker (small
dot at the boundary).
Lane-aware (changeover is per-lane, not per-stage). Pure rendering —
no schema impact.
### D7 — Drag-drop interaction model
- Drag block horizontally → re-time (snap to 15 min)
- Drag block vertically → re-lane within stage OR re-stage to adjacent row
- Drag right edge → resize duration (snap to 15 min, min 15 min)
- Drag onto wachtrij column → park (set `stage_id = null`)
- Drag from wachtrij card onto canvas → schedule (set
`stage_id + start_at + end_at + lane`)
- Drag is committed on mouseup with single transactional API call
(see D18)
- Failed call (validation error, version mismatch, transactional
conflict) reverts block to its origin position with a toast and
refetches the day's timetable
Click-after-drag suppression: 3 px Manhattan threshold in the move
case, 4 px in the wachtrij case. Capture-phase one-shot listener
suppresses the synthetic click. Implemented once as `useDragOrClick`
composable (not three times like the prototype).
### D8 — Custom Vue components, not FullCalendar
Confirmed unchanged from v0.1. Three blockers: licence, render-hook
fighting, cross-day stage filtering mismatch with FC's resource
model. Custom Vue components on top of HTML5 PointerEvents give total
control. Estimate: ~5-6 frontend days for the rendering core
(prototype audit confirms this is realistic).
### D9 — `engagement.booking_status` is 9-state PHP enum
**Revised from v0.1's 6-state.** Eight statuses + `option_expires_at`
mechanism:
```php
namespace App\Enums\Artist;
enum ArtistEngagementStatus: string {
case Draft = 'draft'; // interne wens, nog geen contact
case Requested = 'requested'; // aangevraagd bij agent
case Option = 'option'; // hold gezet (require option_expires_at)
case Offered = 'offered'; // offer uit (fee + rider verstuurd)
case Confirmed = 'confirmed'; // mondeling/email bevestigd
case Contracted = 'contracted'; // contract getekend
case Cancelled = 'cancelled'; // was rond, nu niet meer
case Rejected = 'rejected'; // wij wijzen af (eindstatus)
case Declined = 'declined'; // agent zei nee (eindstatus)
}
```
`Pending` from the prototype = `Requested` in this enum (per D22).
`Cancelled``Declined``Rejected` — three distinct end-states
for reporting.
Options must have an `option_expires_at` datetime (validated by
`UpdateArtistEngagementRequest` when status=Option). Scheduled job
`DemoteExpiredOptions` runs daily, demotes expired options to
`Draft` with activity log entry + notification to project leader.
### D10 — Org-level Artist + per-event ArtistEngagement
**The fundamental architectural shift.** Replaces v0.1's
"artists.event_id" model.
```
artists # Master record (org-level)
id ULID PK
organisation_id ULID FK → organisations
name string
slug string (unique per org, used for URLs)
default_genre_id ULID FK nullable → genres
default_draw int nullable # expected pull
star_rating tinyint nullable (15)
home_base_country string(2) nullable # ISO 3166-1 alpha-2
agent_company_id ULID FK nullable → companies (type=agency)
notes text nullable
deleted_at timestamp nullable
artist_engagements # Per-event booking
id ULID PK
organisation_id ULID FK → organisations (denormalised)
artist_id ULID FK → artists
event_id ULID FK → events (festival or flat event)
booking_status enum (D9)
project_leader_id ULID FK nullable → users
fee_amount decimal(10,2) nullable
fee_currency string(3) default 'EUR'
fee_type enum nullable
buma_applicable bool default true
buma_percentage decimal(5,2) default 7.00
buma_handled_by enum (D26)
vat_applicable bool default true
vat_percentage decimal(5,2) default 21.00
deal_breakdown JSON nullable
deposit_percentage decimal(5,2) nullable
deposit_due_date date nullable
balance_due_date date nullable
payment_status enum default 'none'
crew_count int default 0
guests_count int default 0
requested_at datetime nullable
option_expires_at datetime nullable
advance_open_from datetime nullable
advance_open_to datetime nullable
portal_token ULID unique nullable # tour-manager portal
advancing_completed_count int default 0 # cached, observer-maintained
advancing_total_count int default 0 # cached, observer-maintained
notes text nullable
deleted_at timestamp nullable
```
**Field-allocation rationale (master vs engagement):**
- On `artists` (master): name, default genre/draw, star_rating, agent
company, home base, notes that travel across events
- On `artist_engagements` (per-event): booking_status, fee + deal
info, milestones, advancing, portal_token, project leader, crew/
guests counts
**`milestone_flags`** from SCHEMA.md §3.5.7's planned design is
**replaced** by status enum transitions + activity log. The eight
boolean toggles in the original schema were a state-machine in
disguise.
`UNIQUE(artist_id, event_id)` — one engagement per artist per event.
Multiple performances under one engagement (D17).
### D11 — Stages festival-level, performances sub-event-level
For a festival (`event_type='festival'`, has children):
- Stages hang on `stages.event_id = festival.id` (the parent)
- `stage_days.event_id` references sub-events (the children)
- Performances hang on `performances.event_id = sub_event.id`
For a flat event (`event_type='event'`, no parent, no children):
- Stages hang on `stages.event_id = event.id` (itself)
- `stage_days.event_id = event.id` (one row per stage activating
the event itself — kept for schema uniformity)
- Performances hang on `performances.event_id = event.id`
For a series (`event_type='series'`, has children): same as festival,
each edition is a "show host" that parallels a sub-event.
The timetable view route is always at the highest level
(`/events/{festival}/timetable` or `/events/{event}/timetable` for
flat). Day-tabs are an in-page filter UI element, not separate routes.
### D12 — Wachtrij = nullable `stage_id` filter (no separate table)
Confirmed from prototype audit §7.5. `performances.stage_id` is
nullable. `stage_id IS NULL` ⇔ parked/wachtrij. Drag from wachtrij
to canvas = `PATCH` with `stage_id + start_at + end_at + lane`.
Drag from canvas to wachtrij = `PATCH` with `stage_id = NULL`.
Indexing: `INDEX (event_id, stage_id, start_at, end_at)` — works
with `WHERE stage_id IS NULL` for wachtrij query.
No separate `parked_performances` table. No origin field. The
activity log carries provenance.
### D13 — Lanes IN v1 with cascade-bump
`performances.lane` (`unsigned tinyint`, default 0). 0-indexed,
grows downward inside a stage row.
Two-pass lane resolution:
- **Pass 1 (explicit):** items with non-null `lane` go to that lane
(bumped down on conflict)
- **Pass 2 (auto):** items with null `lane` go to lowest free lane
Both passes run **server-side** on read (per D19), result is
exposed as `lane_resolved` on the `PerformanceResource`. Client
runs Pass 2 only for optimistic-update preview during drag; the
authoritative value comes from the server response after PATCH.
Cascade-bump: when a block is dropped onto a busy lane × time slot,
the existing block bumps to `lane + 1`, recursively. Implemented
**transactionally on the server** (per D18) — not as N sequential
client PATCHes like the prototype.
### D14 — Optimistic locking via `version` column
`performances.version` (int, default 0, bumped on every UPDATE).
Client sends `version` in PATCH body. Server returns:
- 200 + new resource (with bumped version) on success
- 409 Conflict with `{conflict: 'version_mismatch', current_version:
N, server_data: {…}}` on mismatch
Client on 409: shows toast "Iemand anders heeft dit zojuist
aangepast — venster ververst"; refetches day timetable; reverts
block to origin position.
Necessary because two programmers in the same timetable is reality
at festival scale.
### D15 — Form Builder integration via `artist_advance` purpose
The advance/rider data layer is **not** a new `artist_riders` table.
Reuse the existing `artist_advance` purpose (ARCH-FORM-BUILDER §3.2.5):
- One `form_schema` per organisation, purpose=`artist_advance`,
`section_level_submit=true`, seeded from `ArtistAdvanceDefault`
(Session 3)
- Sections: General Info, Contacts, Production, Technical Rider,
Hospitality (organisation may add custom sections)
- Per `artist_engagement`: one `form_submission` keyed by
`engagement.portal_token`
- Tour manager fills sections via `/p/artist/{token}` portal
- `advance_sections` rows link to `engagement_id` (not `artist_id`
as currently planned in §3.5.7) — this is the schema correction
- `advancing_completed_count` on engagement = count of
`advance_sections WHERE submission_status='approved'` for that
submission
`form_submissions.subject_type='artist'` + `subject_id=artist.id`
(master) is preserved. `form_submissions.event_id` (denormalised
per WS-4) provides the engagement context. The `ArtistResolver::
fromPortalToken` resolver looks up the engagement from
`portal_token`, returns the artist as subject, populates `event_id`
from engagement.
### D16 — Realtime broadcasts out of v1
No Echo channels, no Reverb broadcasts in v1. Defer to ART-15
(after notification framework lands, after Accreditation Engine).
Single-user happy path is the v1 target. Multi-user collision is
caught by D14 optimistic locking.
### D17 — Engagement → Performance is 1:N
One `artist_engagement` can have multiple `performances`. Booker
decides whether multi-show bookings are one engagement (single deal,
one fee, one advancing trajectory) or multiple engagements (separate
deals, separate fees).
Common scenarios:
| Scenario | Engagements | Performances |
|---|---|---|
| Single set | 1 | 1 |
| Two sets same day, different stages, one weekend deal | 1 | 2 |
| Friday + Saturday under one combined deal | 1 | 2 |
| Friday via Mojo + Saturday via Friendly Fire (separate fees) | 2 | 2 |
Cancel engagement → all performances cascade soft-delete. Cancel
single performance within active engagement → that performance
soft-deleted, engagement keeps running.
Same-artist conflict warnings (per ART-08, deferred):
- Cross-stage overlap within same engagement → warn
- Cross-stage overlap across engagements with same artist_id → warn
### D18 — Server-side transactional move endpoint
The cascade-bump algorithm runs in a single DB transaction on the
backend, not as N sequential PATCHes. New endpoint:
```
POST /api/v1/events/{event}/timetable/move
Body: {
performance_id: ULID,
target_stage_id: ULID | null, # null = park
target_start_at: datetime | null,
target_end_at: datetime | null,
target_lane: int | null, # null = auto-pack server-side
version: int # optimistic lock on the dragged perf
}
Response 200: {
performance: PerformanceResource,
cascade: PerformanceResource[] # all bumped siblings (incl. new versions)
}
Response 409: { conflict: 'version_mismatch', current_version, server_data }
Response 422: validation errors
```
Server logic:
1. `BEGIN TRANSACTION`
2. `SELECT … FOR UPDATE` on the dragged perf and all stage-day
siblings (lock to prevent concurrent cascades)
3. Validate `version` matches → 409 if not
4. Apply `target_*` fields, run cascade-bump algorithm
5. Bump `version` on every modified row
6. Activity-log entries: one parent `performance.moved` event with
cascade-count metadata, linked child entries for bumped siblings
7. `COMMIT`
Idempotency-Key required (per ARCH §10), 24-hex from UUID v4.
Ordinary `PATCH /performances/{id}` remains for status/notes/etc.
edits that do not affect placement.
### D19 — Server-side lane resolution on read
`PerformanceResource` returns both `lane` (raw, persisted) and
`lane_resolved` (after Pass 1 + Pass 2 for the entire stage-day).
Two clients viewing the same timetable see identical layouts.
Client uses `lane_resolved` for rendering; `lane` only for editing
(in StageEditor advanced view, never v1 default).
### D20 — Keyboard a11y model
The prototype is mouse-only. Crewli production requires a11y. The
v1 keyboard model:
| Key | Action |
|---|---|
| Tab | Move focus through stages → blocks → wachtrij → header controls |
| Arrow ←/→ | Selected block: nudge ±15 min |
| Shift+Arrow ←/→ | Nudge ±60 min |
| Arrow ↑/↓ | Selected block: change lane within stage; at lane boundary, jump to adjacent stage row (skip if not active on day) |
| Enter / Space | Selected block: open popover |
| Escape | Close popover OR cancel drag (if active) OR blur block focus |
| Delete | Selected block: confirm dialog → delete |
| `[` / `]` | Selected block: move to previous/next stage (preserves time + lane) |
| Cmd/Ctrl+Z | Undo last move (deferred to ART-07; v1 toast says "Undo: Cmd+Z (binnenkort)") |
`aria-label` on blocks: `"{artist}, {stage}, {start_time}{end_time}, status {status}, advancing {n}/{m}"`.
`role="application"` on the canvas with screen-reader instructions
hidden via `cw-sr-only`. Focus ring uses `--accent` colour, 2 px
solid, 2 px offset.
Keyboard-driven drag is implemented as a discrete "move mode": Enter
on a focused block enters move mode (announced via aria-live);
arrows move block; Enter commits, Escape cancels.
### D21 — Status colours via CSS tokens
Per-status colours live in `apps/app/src/styles/tokens/timetable.scss`
as CSS custom properties:
```scss
:root {
--tt-status-draft-bg: #f1efe9;
--tt-status-draft-border: #dcd9d1;
--tt-status-draft-fg: #3a3830;
--tt-status-draft-dot: #a09c92;
--tt-status-requested-bg: #fff6e0;
--tt-status-requested-border:#f0d99a;
--tt-status-requested-fg: #5d4612;
--tt-status-requested-dot: #d9a93c;
--tt-status-option-bg: #f3eefa;
/* … */
--tt-status-confirmed-bg: #e8f8f0;
/* … */
--tt-status-contracted-bg: #e6f1fb;
/* … */
--tt-status-cancelled-bg: #f5f3ef;
/* hatched overlay via CSS gradient */
/* … */
--tt-status-rejected-bg: #fbeaec;
/* … */
--tt-status-declined-bg: #f7eee9;
/* … */
--tt-status-offered-bg: #fef5e7;
/* … */
}
```
`PerformanceBlock.vue` consumes via `style="background-color: var
(--tt-status-{{status}}-bg)"`. Per-tenant override (ART-14) deferred:
the system can later inject org-specific colours by setting the
custom properties on a parent `[data-org-id]` selector.
### D22 — Pending = `booking_status='requested'` (no separate table)
The prototype's `pending` collection (a third runtime set besides
performances and parked) is NOT modelled in production. What the
prototype calls "pending" is conceptually identical to "we have
asked the agent, awaiting answer" — that is exactly status
`requested` in the 9-state enum (D9).
`requested_at` datetime + `notes` text on `artist_engagements`
captures everything the prototype's `pending` row carried (id,
artist_id, requested_on, note).
Wachtrij filtering: a `requested`-status engagement with no
performances yet shows up in wachtrij as a card. Drag onto canvas =
PATCH adds the first performance with `stage_id + start_at +
end_at + lane`. Status stays `requested` until booker manually
moves it to `option`/`confirmed`.
### D23 — `stages.sort_order` for persistent reorder
Stages can be drag-reordered (drag the stage row's left handle).
Reorder persists via `stages.sort_order` (int, default 0). Endpoint:
`PATCH /events/{event}/stages/order` with `{stage_ids: ULID[]}`
replaces the order atomically.
Default order: by creation timestamp. Bookers reorder to match
physical walk-through, headliner priority, or genre clustering.
### D24 — Genre via org-level `genres` table
```
genres
id ULID PK
organisation_id ULID FK → organisations
name string(40)
color string(7) nullable # optional accent
sort_order int default 0
is_active bool default true
UNIQUE(organisation_id, name)
```
Each organisation maintains its own genre list. System-seeds nothing
— org chooses what is relevant (Hardstyle / Techno / Hollands /
classical / cabaret / DJ-set / live-band / podcast-recording).
Configurable via `/organisation/settings/genres`.
`artists.default_genre_id` is the master assignment. Engagements do
NOT override genre per event (an artist's genre doesn't change
between bookings).
Modal "Add performance" pre-fills genre from the master artist;
the modal does not allow genre editing here (genre edits happen on
the artist master page).
### D25 — Capacity warning + B2B detection IN v1
Both included. They are visible features in the prototype; bookers
will miss them otherwise.
- **Capacity warning:** computed client-side per block via
`artist.default_draw > stage.capacity * 1.1`. Renders an orange
triangle icon in the block's warn cluster + tooltip "Verwachte
trekkracht (X) overschrijdt zaalcapaciteit (Y)".
- **B2B marker:** computed client-side per stage-day via the
`findB2B` algorithm (≤5 min gap between consecutive performances
same lane). Renders a small dot at the boundary edge.
Both are derivations only — no schema impact, no API additions.
For festivals with 100+ performances per day, computation runs in
~1ms (per prototype audit §7.16). Server-side computation deferred
to ART-04 if performance issues arise.
### D26 — Buma + VAT handling per engagement
Buma (rights collection society NL) is 7% of fee. VAT is 21%.
Three configurations matter:
```php
namespace App\Enums\Artist;
enum BumaHandledBy: string {
case Organisation = 'organisation'; // wij dragen 7% af
case BookingAgency = 'booking_agency'; // boekingskantoor doet het
case NotApplicable = 'not_applicable'; // non-music acts
}
```
**Defaults on engagement creation:** `buma_applicable=true`,
`buma_percentage=7.00`, `buma_handled_by=Organisation`,
`vat_applicable=true`, `vat_percentage=21.00`.
**Auto-flip rule:** when `agent_company_id` on the artist points to
a `companies` row with `handles_buma=true`, the engagement form
defaults `buma_handled_by=BookingAgency`. Booker can override.
**Derived calculations (live, not stored):**
```
buma_amount = fee_amount × buma_percentage / 100
(only if buma_applicable AND buma_handled_by=Organisation)
vat_grondslag = fee_amount + (buma_amount if Organisation else 0)
vat_amount = vat_grondslag × vat_percentage / 100 (if vat_applicable)
total_cost = fee_amount + buma_amount + vat_amount + sum(deal_breakdown)
```
`companies` table gets a new column `handles_buma` (bool, default
false). Migration adds it; existing rows default to false.
Backlog: FIN-01 (Buma export per festival), FIN-02 (forecast widget).
### D27 — Soft-delete preserves engagements; visual marker
Soft-deleting a master `Artist` (set `deleted_at`) does **NOT** cascade
to `artist_engagements`. Historical engagements remain fully visible
and functional — those bookings happened in the past, that history
must be preserved. Future engagements of a soft-deleted artist also
remain visible and editable, but render with a clear "verwijderd"
banner so the booker knows the master record has been archived.
**Behavior matrix:**
| Surface | Trashed artist behaviour |
|---|---|
| Master Artist Library list | Default hidden. Toggle "Toon verwijderde" reveals with red strike-through name + "Verwijderd op {date}" badge. |
| Master Artist Detail page | Accessible via direct URL. Prominent banner: "Deze artiest is verwijderd op {date} door {user}. {N} historische boekingen blijven beschikbaar." |
| Engagement detail page (organizer) | Banner above artist info: "⚠ Deze artiest is verwijderd uit de organisatie-bibliotheek." Engagement remains fully editable. |
| Performance block (timetable) | Trashed-artist marker: dashed border + small archive-icon overlay in the warn cluster. Hover tooltip explains. |
| Add Engagement modal — artist dropdown | Filter `WHERE deleted_at IS NULL`. No checkbox to include trashed (deliberate friction; restore via Master Library is the correct path). |
| Cross-event analytics, "active roster" counts | Trashed artists excluded. |
| Cross-event analytics, historical aggregates | Trashed artists included (the bookings happened). |
**Restore:** `POST /organisations/{org}/artists/{artist}/restore`
unsets `deleted_at`. Permission: `organisations.manage_artists`.
Activity log captures both delete and restore events. Once restored,
all banners disappear immediately on next render.
**Hard-delete blocking:** there is no hard-delete endpoint for artists
in v1. `deleted_at` is the terminal state. GDPR erasure is a separate
flow (handled via the existing Person erasure runbook pattern, scoped
to the artist's contact rows when needed — backlog ART-24).
## 5. Schema impact
This is a substantial migration. Sessions 1 sets up new tables; the
existing planned `§3.5.7` schema is **partly replaced**.
### 5.1 New tables
```
artists # NEW — org-level master
artist_engagements # NEW — per-event booking
genres # NEW — org-level configurable list
stage_days # NEW — pivot stage × event (sub-event or flat)
performances # NEW — per-engagement scheduled show
advance_sections # NEW — engagement-scoped (was artist-scoped in §3.5.7 plan)
advance_submissions # NEW
artist_contacts # NEW — master contacts (FK to artist, not engagement)
```
Tables NOT created (from §3.5.7's planned design):
```
artist_riders — replaced by Form Builder (artist_advance purpose)
itinerary_items — out of scope v1 (could become Form Builder later)
```
### 5.2 Modified tables
```
companies
+ handles_buma bool default false
(existing 'agency' value in type enum is reused unchanged)
```
### 5.3 Detailed table specs
#### `artists` (replaces planned `artists` in §3.5.7)
| Column | Type | Notes |
|---|---|---|
| `id` | ULID | PK |
| `organisation_id` | ULID FK | → organisations |
| `name` | string(120) | |
| `slug` | string(120) | for URL paths |
| `default_genre_id` | ULID FK nullable | → genres |
| `default_draw` | int nullable | expected pull (capacity-warn input) |
| `star_rating` | tinyint nullable | 15 |
| `home_base_country` | string(2) nullable | ISO 3166-1 alpha-2 |
| `agent_company_id` | ULID FK nullable | → companies (type=agency) |
| `notes` | text nullable | |
| `created_at`, `updated_at`, `deleted_at` | | soft delete |
**Unique:** `UNIQUE(organisation_id, slug)`
**Indexes:** `(organisation_id, name)`, `(default_genre_id)`,
`(agent_company_id)`
**Soft delete:** yes
**Scope:** `OrganisationScope` (direct FK)
#### `artist_engagements`
| Column | Type | Notes |
|---|---|---|
| `id` | ULID | PK |
| `organisation_id` | ULID FK | → organisations (denormalised for scope) |
| `artist_id` | ULID FK | → artists |
| `event_id` | ULID FK | → events (festival or flat event) |
| `booking_status` | enum | D9 — 9 values |
| `project_leader_id` | ULID FK nullable | → users |
| `fee_amount` | decimal(10,2) nullable | |
| `fee_currency` | string(3) default 'EUR' | |
| `fee_type` | enum nullable | flat/door_split/guarantee_plus_split |
| `buma_applicable` | bool default true | |
| `buma_percentage` | decimal(5,2) default 7.00 | |
| `buma_handled_by` | enum | D26 — 3 values |
| `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` | enum default 'none' | none/deposit_paid/paid_in_full |
| `crew_count` | int default 0 | |
| `guests_count` | int default 0 | |
| `requested_at` | datetime nullable | when status set to Requested |
| `option_expires_at` | datetime nullable | required when status=Option |
| `advance_open_from` | datetime nullable | |
| `advance_open_to` | datetime nullable | |
| `portal_token` | ULID unique nullable | tour-manager portal |
| `advancing_completed_count` | int default 0 | observer-maintained |
| `advancing_total_count` | int default 0 | observer-maintained |
| `notes` | text nullable | |
| `created_at`, `updated_at`, `deleted_at` | | soft delete |
**Unique:** `UNIQUE(artist_id, event_id)`, `UNIQUE(portal_token)`
**Indexes:** `(organisation_id)`, `(event_id, booking_status)`,
`(option_expires_at)` (for the demote-job)
**Soft delete:** yes (cascades to performances, advance_sections via observer)
**Scope:** `OrganisationScope` (direct FK on `organisation_id`)
#### `stages` (replaces planned `stages` in §3.5.7)
| 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 | for capacity-warn |
| `sort_order` | int default 0 | D23 |
| `created_at`, `updated_at` | | |
**Unique:** `UNIQUE(event_id, name)`
**Indexes:** `(event_id, sort_order)`
**Soft delete:** no (per v0.1 §5; deleting a stage is rare and
destructive; soft-delete adds query complexity without safety win)
**Scope:** `OrganisationScope` (FK-chain via event)
#### `stage_days` (revised: event_id instead of day_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:** `UNIQUE(stage_id, event_id)`
**Indexes:** `(event_id)`
**Soft delete:** no (pure pivot)
The pivot semantics: for a festival, each stage has multiple
`stage_days` rows (one per active sub-event). For a flat event,
each stage has exactly one `stage_days` row referencing the event
itself. This uniformity simplifies the query layer.
#### `performances` (replaces planned `performances` in §3.5.7)
| 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 |
| `lane` | unsigned tinyint default 0 | D13 |
| `start_at` | datetime | ⩾ event.start_at, < event.end_at |
| `end_at` | datetime | > start_at, ⩽ event.end_at |
| `version` | int default 0 | D14 — optimistic lock |
| `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)` for the lane resolver
**Soft delete:** yes (cascade with engagement via observer)
**Scope:** `OrganisationScope` (FK-chain via event → engagement)
#### `genres`
Spec in D24.
#### `advance_sections` (revised: engagement_id, not artist_id)
| Column | Type | Notes |
|---|---|---|
| `id` | ULID | PK |
| `engagement_id` | ULID FK | → artist_engagements **(was artist_id)** |
| `name` | string(80) | |
| `type` | enum | guest_list/contacts/production/custom |
| `is_open` | bool default false | |
| `open_from` | datetime nullable | |
| `open_to` | datetime nullable | |
| `sort_order` | int default 0 | |
| `submission_status` | enum | open/pending/submitted/approved/declined |
| `last_submitted_at` | timestamp nullable | |
| `last_submitted_by` | string nullable | tour manager name from form |
| `submission_diff` | JSON nullable | |
**Indexes:** `(engagement_id, is_open)`, `(engagement_id, submission_status)`
#### `advance_submissions`
Unchanged from §3.5.7 plan, FK adjusted to `advance_section_id`
which now traces to engagement, not artist.
#### `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 / booker / etc. |
| `is_primary` | bool default false | one primary per role |
| `receives_briefing` | bool default false | |
| `receives_infosheet` | bool default false | |
**Indexes:** `(artist_id, role)`
V1 keeps contacts on the master artist. Engagement-specific contacts
deferred (most contacts persist across events for the same artist).
### 5.4 Soft delete strategy
| Table | Soft delete | Cascade behaviour |
|---|---|---|
| `artists` | Yes | **NO cascade** to engagements (D27). Master record marked `deleted_at`; engagements remain fully functional with visual "verwijderd" markers. |
| `artist_engagements` | Yes | Cascade to performances + advance_sections via observer |
| `performances` | Yes | Direct cascade with engagement |
| `advance_sections` | No | Hard delete with engagement |
| `advance_submissions` | No | Audit-immutable; remain for retention compliance |
| `artist_contacts` | No | Hard delete with artist master |
| `stages` | No | Performances cascade-park (set stage_id=null) |
| `stage_days` | No | Pure pivot, hard delete |
| `genres` | No (use `is_active`) | Re-link or block depending on referencing artists |
### 5.5 Cross-doc updates
- `SCHEMA.md` §3.5.7 fully rewritten in Session 1 (move from
`ARCH-PLANNED-MODULES.md` per the established pattern)
- `ARCH-FORM-BUILDER.md` §3.2.5 (`artist_advance` lifecycle)
updated to reflect engagement-scoped sections instead of
artist-scoped
- `ARCH-FORM-BUILDER.md` §17.3 (purpose registry) gets a footnote
about engagement context resolution
- `BACKLOG.md`: ARCH-09 + ART-02 + ART-03 closed; ART-04 through
ART-23 + FIN-01 + FIN-02 added
## 6. Routes & API
All routes scoped to `events.{event}` with `OrganisationScope`
enforced via FK-chain.
### 6.1 Org-level resources
```
GET /api/v1/organisations/{org}/artists # paginated, search by name/slug, filter by genre/agent_company/has_upcoming_engagements, sort by name|last_engagement_at|first_engagement_at, ?with_trashed=true|trashed_only=true to include soft-deleted
POST /api/v1/organisations/{org}/artists # master create
GET /api/v1/organisations/{org}/artists/{artist} # full detail incl. engagements summary (lifetime + upcoming)
PATCH /api/v1/organisations/{org}/artists/{artist} # master update
DELETE /api/v1/organisations/{org}/artists/{artist} # soft-delete; engagements untouched (D27)
POST /api/v1/organisations/{org}/artists/{artist}/restore # unset deleted_at; logged
GET /api/v1/organisations/{org}/genres # config list
POST /api/v1/organisations/{org}/genres
PATCH /api/v1/organisations/{org}/genres/{genre}
DELETE /api/v1/organisations/{org}/genres/{genre} # block if referenced
```
### 6.2 Event-level resources
```
GET /api/v1/events/{event}/engagements # paginated, search, status filter
POST /api/v1/events/{event}/engagements # create from existing or new artist
GET /api/v1/events/{event}/engagements/{engagement} # full detail
PATCH /api/v1/events/{event}/engagements/{engagement} # status, deal info, etc.
DELETE /api/v1/events/{event}/engagements/{engagement} # cancel + soft-delete
GET /api/v1/events/{event}/stages # ordered by sort_order
POST /api/v1/events/{event}/stages
PATCH /api/v1/events/{event}/stages/{stage}
DELETE /api/v1/events/{event}/stages/{stage} # cascade-park performances
PATCH /api/v1/events/{event}/stages/order # {stage_ids: ULID[]}
PUT /api/v1/events/{event}/stages/{stage}/days # {event_ids: ULID[]} replaces matrix
PUT /api/v1/events/{event}/lineup # bulk matrix replacement
GET /api/v1/events/{event}/performances?day={subevent} # filter by sub-event
GET /api/v1/events/{event}/performances?stage_id=null # wachtrij
POST /api/v1/events/{event}/performances # create (or park if stage_id null)
GET /api/v1/events/{event}/performances/{perf}
PATCH /api/v1/events/{event}/performances/{perf} # status/notes only
DELETE /api/v1/events/{event}/performances/{perf}
POST /api/v1/events/{event}/timetable/move # D18 — transactional cascade
```
### 6.3 Engagement sub-resources
```
GET /api/v1/engagements/{engagement}/advance-sections
POST /api/v1/engagements/{engagement}/advance-sections
PATCH /api/v1/advance-sections/{section} # toggle is_open, etc.
DELETE /api/v1/advance-sections/{section}
GET /api/v1/engagements/{engagement}/contacts # via artist master
POST /api/v1/engagements/{engagement}/portal-token # generate or rotate
DELETE /api/v1/engagements/{engagement}/portal-token # revoke
```
### 6.4 Portal endpoints (token-authenticated)
Existing `/p/*` routes get the artist portal:
```
GET /api/v1/p/artist/{token} # event + engagement context
GET /api/v1/p/artist/{token}/advance # form_schema + sections
POST /api/v1/p/artist/{token}/advance/sections/{section} # submit one section
GET /api/v1/p/artist/{token}/crew-list # placeholder rows
PATCH /api/v1/p/artist/{token}/crew-list/{person} # tour manager edits crew
```
`PortalTokenMiddleware` resolves engagement context from
`portal_token` and exposes `portal_engagement` + `portal_event`
attributes.
### 6.5 Resource shapes
`PerformanceResource`:
```json
{
"id": "01HX...",
"engagement_id": "01HX...",
"event_id": "01HX...",
"stage_id": "01HX..." | null,
"lane": 0,
"lane_resolved": 0, // D19
"start_at": "2026-07-10T22:00:00+02:00",
"end_at": "2026-07-11T00:00:00+02:00",
"version": 3,
"warnings": ["overlap", "capacity"], // populated server-side
"engagement": {
"id": "01HX...",
"booking_status": "confirmed",
"advancing_completed_count": 4,
"advancing_total_count": 5,
"artist": {
"id": "01HX...",
"name": "Devin Wild",
"slug": "devin-wild",
"default_genre": { "id": "...", "name": "Hardstyle" },
"default_draw": 4200,
"initials": "DW" // computed by resource
}
},
"stage": {
"id": "01HX...",
"name": "Hardstyle District",
"color": "#e85d75",
"capacity": 4500
} | null
}
```
`Idempotency-Key` header required on `POST /performances`,
`POST /timetable/move`, `POST /engagements`. Same pattern as
form_submissions.
## 7. Frontend architecture
```
apps/app/src/pages/events/[id]/timetable/
index.vue — page entry, day-tab state, ?day query sync
TimetableGrid.vue — scroll container, time axis, stage rows
StageRow.vue — left cell + draggable performance row + reorder handle
StageHeaderCell.vue — swatch + name + capacity + day chips
PerformanceBlock.vue — single block; emits drag/resize/click; CSS-tokens for status
PerformancePopover.vue — floating detail; teleported; closes on Escape
Wachtrij.vue — sidebar with search + status multi-select + genre pills
WachtrijCard.vue — single parked card
AddPerformanceDialog.vue — modal create
StageEditor.vue — single-stage modal CRUD with cascade preview
LineupMatrix.vue — bulk stages × sub-events editor modal
EmptyDayState.vue — "no stages on this day, copy from..."
apps/app/src/pages/events/[id]/artists/[engagement]/
index.vue — engagement detail page with tabs
OverviewTab.vue — deal info + Buma + crew/guests + status timeline
PerformancesTab.vue — list of performances within event
AdvancingTab.vue — section list with Form Builder section components
ContactsTab.vue — master contacts (read-only mostly)
CrewGuestsTab.vue — crowd_list view + tour-manager-fillable
ActivityLogTab.vue — Spatie activity log filtered by subject
apps/app/src/pages/p/artist/[token]/
index.vue — portal landing
PerformanceCard.vue — show details (time, stage, load-in)
AdvanceFormSection.vue — reuse Form Builder section render
apps/app/src/pages/organisation/artists/
index.vue — Master Artist Library: search, filter sidebar, paginated VDataTable, "Toon verwijderde" toggle
ArtistRow.vue — list row: avatar, name, genre, last engagement, # engagements, status badge if trashed
AddArtistDialog.vue — create master (or pick existing — also reused inline from "+ Performance")
RestoreArtistDialog.vue — restore-confirm with summary of N engagements
apps/app/src/pages/organisation/artists/[artist]/
index.vue — Master Artist Detail with 4 tabs
OverviewTab.vue — name, default_genre, default_draw, star_rating, home_base, agent_company, notes
EngagementsTab.vue — cross-event history table: event, dates, status, fee, performances count, link
ContactsTab.vue — master `artist_contacts` CRUD
ActivityLogTab.vue — Spatie activity log filtered by Artist subject
TrashedBanner.vue — shared banner component (also rendered by EngagementOverviewTab when artist is trashed)
apps/app/src/composables/timetable/
useTimetable.ts — TanStack queries: stages, performances per day
useTimetableMutations.ts — performance CRUD + the move endpoint with optimistic update + rollback
useStageDays.ts — matrix replacement
useDragOrClick.ts — single composable for threshold + suppression (replaces 3-place duplicate)
useLaneCascade.ts — pure preview-only logic during drag (server is authoritative)
usePointerDrag.ts — PointerEvents-based drag (modern replacement for mousedown stack)
useTimetableKeyboard.ts — D20 keyboard model
apps/app/src/composables/engagements/
useEngagement.ts — single-engagement TanStack query
useEngagementMutations.ts — status transitions, deal info edits
apps/app/src/lib/timetable/
snap.ts, conflicts.ts, b2b.ts — pure logic ports of helpers.js
laneAssign.ts — Pass 2 logic for client-side preview
capacity.ts — capacity-warn computation
apps/app/src/types/
artist.ts, engagement.ts, performance.ts, stage.ts (zod schemas)
apps/app/src/api/
artists.ts, engagements.ts, stages.ts, performances.ts, timetable.ts
```
State pattern:
- TanStack Query owns canonical server state (per-event timetable cached
with stale-time 30s; mutations invalidate selectively)
- Pinia store `useTimetableStore` owns ONLY UI state (selectedDay,
selectedPerformanceId, popoverPosition, dragState, keyboardFocusId)
— never duplicate server data
- Optimistic updates on drag/move via TanStack `onMutate` rollback
pattern; on 409 toast + refetch
- Server response from `POST /timetable/move` patches both the moved
perf AND every cascade entry into the cache in one operation
Form validation: VeeValidate + Zod on all dialogs + the engagement
detail forms. Schemas mirrored from `apps/app/src/types/`.
## 8. Activity log
Spatie ActivityLog with `LogsActivity` trait on:
- `Artist` (master), `ArtistEngagement`, `Stage`, `Performance`
- `Genre` (config-level changes)
Logged events:
- `artist.created`, `artist.updated`, `artist.deleted`
- `artist_engagement.created`, `artist_engagement.status_changed`,
`artist_engagement.deal_info_updated`, `artist_engagement.cancelled`,
`artist_engagement.option_expired` (system-causer for the daily job)
- `stage.created`, `stage.updated`, `stage.deleted`,
`stage.day_added`, `stage.day_removed`, `stage.reordered`
- `performance.created`, `performance.updated`, `performance.deleted`
- `performance.moved` — D18 cascade — single parent entry with
`properties.cascade_count`, `properties.cascaded_ids[]`
- `performance.parked`, `performance.unparked`
- `genre.created`, `genre.updated`, `genre.deactivated`
Capture before/after on: name, color, capacity (stages); booking_status,
fee_amount, buma_handled_by, option_expires_at (engagements);
start_at, end_at, stage_id, lane, version (performances).
Activity log entries scoped to `organisation` + `event` for the
event audit log filter.
## 9. Authorization (Policy)
```
ArtistPolicy:
viewAny(User, Organisation) — member of organisation
view(User, Artist) — same + organisation match
create(User, Organisation) — 'organisations.manage_artists'
update(User, Artist) — same as create
delete(User, Artist) — same as create + no active engagements
ArtistEngagementPolicy:
viewAny(User, Event) — event member + 'events.view_program'
view(User, ArtistEngagement) — viewAny + scope match
create(User, Event) — 'events.manage_program'
update(User, ArtistEngagement) — same as create
delete(User, ArtistEngagement) — same as create
StagePolicy: same as v0.1 §9
PerformancePolicy: same as v0.1 §9
GenrePolicy:
viewAny, view: any org member
create/update/delete: 'organisations.manage_settings'
```
New permissions:
- `events.view_program` — read-only timetable access
- `events.manage_program` — full CRUD on engagements/performances/stages
- `organisations.manage_artists` — master artist CRUD
- `organisations.manage_settings` — genre config
New roles:
- `program_manager` — gets `events.manage_program` +
`organisations.manage_artists`
- `production_assistant` — gets `events.view_program` only
## 10. Validation rules
### 10.1 Status enum (D9)
```php
PerformanceBookingStatus::cases() = [
Draft, Requested, Option, Offered, Confirmed,
Contracted, Cancelled, Rejected, Declined
]
```
State-transition rules (FormRequest validates):
- `Option` requires `option_expires_at` in the future
- `Contracted` requires `fee_amount` set
- `Rejected` and `Declined` are terminal (no further transitions)
- `Cancelled` cascade-soft-deletes performances (observer)
### 10.2 `CreatePerformanceRequest` / `UpdatePerformanceRequest`
```
engagement_id required ulid exists:artist_engagements,id
(scoped to event via engagement.event_id chain)
event_id required ulid exists:events,id
(must equal engagement.event_id; must be flat event
OR a sub-event of the engagement.event_id festival)
stage_id nullable ulid exists:stages,id
(if non-null: must satisfy StageActiveOnEvent rule)
start_at required date format Y-m-d H:i:s
must be ⩾ event.start_at
must be < event.end_at
(sub-event bounds enforced via WithinSubEventBounds rule)
end_at required date format Y-m-d H:i:s
must be > start_at
must be ⩽ event.end_at
(no cross-event-window — naturally bounded by event-config)
lane nullable integer min:0 max:9
(10-lane ceiling; can be raised, but ten lanes is
already pathological in practice)
notes nullable string max:1000
```
Overlap, B2B, capacity-warn are NOT validation errors — they
produce 200 with `warnings: [...]` array. The booker decides.
### 10.3 `CreateArtistEngagementRequest`
```
artist_id required ulid exists:artists,id (org-scoped)
event_id required ulid exists:events,id (org-scoped, must be
flat event OR festival; cannot be a sub-event)
booking_status required enum (D9)
if Option → option_expires_at required + future
if Contracted → fee_amount required
project_leader_id nullable ulid exists:users,id (must be event member)
fee_amount nullable decimal min:0 max:9999999.99
fee_currency nullable string size:3 in:EUR,USD,GBP,...
buma_applicable boolean default true
buma_percentage decimal min:0 max:100 default 7.00
buma_handled_by enum (D26)
crew_count integer min:0 max:200
guests_count integer min:0 max:1000
```
### 10.4 `MoveTimetablePerformanceRequest` (D18)
```
performance_id required ulid exists:performances,id (event-scoped)
target_stage_id nullable ulid exists:stages,id (StageActiveOnEvent or null=park)
target_start_at nullable date format Y-m-d H:i:s
required_unless target_stage_id null
WithinEventBounds rule
target_end_at nullable date format Y-m-d H:i:s
required_unless target_stage_id null
after:target_start_at
target_lane nullable integer min:0 max:9
version required integer (optimistic lock)
```
### 10.5 Stage-day matrix replacement (`PUT /stages/{stage}/days`)
```
event_ids required array min:1
event_ids.* ulid exists:events,id
(each must be sub-event of stage.event_id, OR
if stage.event_id is flat event → must equal stage.event_id)
```
If the request would remove an event_id with scheduled non-cancelled
performances on that stage, return **409 Conflict** with body
`{ performances_on_removed_events: [...] }`. Frontend shows a
matrix-editor confirmation dialog and resends with
`?force_orphan=true`. Orphaned performances persist (soft references,
hidden from views) — recovered when the day re-activates.
## 11. Open questions / future work
**No open questions remain at v0.2.** All material decisions are
locked. Recorded resolutions:
- **R1 — Idempotency-Key window on `POST /timetable/move`:** 60-second
window in Redis (not 12-hour MySQL per ARCH §10 default). Rationale:
a stale 12-hour replay of a cascade-bump can corrupt timetable state
in non-trivial ways. Implementation in Session 2.
- **R2 — Hard-delete artists:** not in v1. `deleted_at` is terminal.
Real GDPR erasure is a separate flow (backlog ART-24).
- **R3 — Engagement-specific contacts (not master-scoped):** deferred.
V1 keeps all `artist_contacts` on the master `artists` record. If
per-event contact divergence becomes operational, split via
`artist_engagement_contacts` polymorphic table in v2.
- **R4 — Per-tenant status colour overrides:** infrastructure ready
via D21 CSS-tokens; activation deferred to ART-14.
Out-of-v1 backlog items are listed in §3.2.
## 12. Implementation order
Six Claude Code sessions, sequential. Estimated **22-26 days total**.
### 12.0 — Authoritative reference materials for ALL frontend sessions
The interactive prototype at `./resources/Crewli - Artist Timetable
Management/` and the prototype audit at
`dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md` (commit
`a57437a`) are the **authoritative UX/UI blueprint** for the
frontend. Claude Code prompts for **Sessions 4, 5, and 6** explicitly
require both as required input before writing any component.
What is to be **ported, not reinvented:**
- Visual layouts (timetable grid, blocks, popover, modals, wachtrij)
- Drag-and-drop snap math and interaction semantics
- Click-vs-drag threshold and click-suppression timing
- Lane assignment + cascade-bump algorithms (verbatim per audit §5)
- Conflict / B2B / capacity-warn detection (verbatim per audit §5)
- Status filter UX (multi-select with counts, "Alle aan/uit", default
cancelled-off)
- Stage editor + lineup matrix layout
- Empty-day copy-from-other-day affordance
- Design tokens (colours, dimensions, typography, animations) per
audit §6
What is **net new for production** (not in prototype):
- Multi-tenancy (`OrganisationScope`, no cross-tenant data in store)
- Real backend persistence with TanStack Query + version-conflict rollback
- Optimistic updates with onError revert
- Server-transactional cascade-bump (D18) + server-side lane
resolution (D19)
- Keyboard a11y model (D20) — from-scratch work, not port
- CSS-tokens for status colours (D21), Vuetify/Vuexy integration
- Master Artist Library (Session 6) — no prototype reference exists
- Activity log integration
The audit document quotes verbatim code for the five pure-logic
functions and the cascade-bump algorithm. Sessions 4+ port these
functions to TypeScript with line-for-line correspondence; new
TypeScript types replace JavaScript implicit types but the algorithm
shape is identical.
### Session 1 — Foundation (3-4 days)
1. Migrations (in order):
- `create_genres_table`
- `create_artists_table`
- `add_handles_buma_to_companies_table`
- `create_artist_contacts_table`
- `create_stages_table` (with sort_order)
- `create_stage_days_table` (event_id-based)
- `create_artist_engagements_table` (with all 26 columns from D10)
- `create_performances_table` (with engagement_id, lane, version, nullable stage_id)
- `create_advance_sections_table` (engagement_id-scoped)
- `create_advance_submissions_table`
2. PHP enums (5 enums per D9, D26, fee_type, payment_status, advance section types)
3. Eloquent models with `HasUlids`, `OrganisationScope`, `LogsActivity` traits
4. Observers: `ArtistEngagementObserver` (advancing aggregate
recompute), `PerformanceObserver` (version bump, cascade soft-delete)
5. Factories + DevSeeder integration (festival with 4 stages, 6 artists,
~12 engagements, ~13 performances reproducing the prototype's fixture)
6. Update `AppServiceProvider::PURPOSE_SUBJECT_FQCN`: string-literal
`'artist' => 'App\\Models\\Artist'` → `Artist::class`
7. SCHEMA.md update (move §3.5.7 from `ARCH-PLANNED-MODULES.md` to
`SCHEMA.md`, fully rewrite to reflect engagement model)
8. ARCH-FORM-BUILDER §3.2.5 update (engagement-scoped advance)
### Session 2 — Backend API + business logic (3-4 days)
1. Spatie permissions + roles (`events.view_program`,
`events.manage_program`, `organisations.manage_artists`,
`organisations.manage_settings`; roles `program_manager`,
`production_assistant`)
2. Policies (5 of them per §9)
3. Services:
- `ArtistService` (master CRUD, duplicate detection by name + slug)
- `ArtistEngagementService` (status transitions with state
machine validation, defaults from artist master, Buma auto-flip)
- `StageService`, `StageDayService` (atomic matrix replace with
orphan-performance handling)
- `PerformanceService` (CRUD, park/unpark)
- `LaneCascadeService` (the D18 transactional move algorithm with
`SELECT … FOR UPDATE` locks)
- `GenreService` (org-level config)
4. FormRequests (§10)
5. Custom validation rules: `StageActiveOnEvent`,
`WithinEventBounds`, `OptionExpiresInFuture`,
`ContractRequiresFee`
6. API Resources with `lane_resolved` computation (D19)
7. Controllers (thin)
8. Routes (§6)
9. Activity log integration (§8)
10. Scheduled command `DemoteExpiredOptions` (daily, 03:00)
11. Tests:
- Feature tests for all endpoints (200/201/204/401/403/404/409/422)
- Policy tests (positive + negative)
- Validation tests
- Cascade-bump transaction tests (concurrent moves return 409)
- Status transition tests (rejected → contracted blocked)
- Buma calculation tests
- Option expiry job test
12. API.md update
### Session 3 — Form Builder integration (1-2 days)
1. `ArtistAdvanceDefault` template seeder with 5 sections (General
Info, Contacts, Production, Technical Rider, Hospitality)
2. Update `ArtistResolver::fromPortalToken` to resolve via engagement
(was via artist; engagement carries the portal_token now)
3. `EngagementPortalController` with the `/p/artist/{token}/*`
endpoint family
4. Wire engagement.event_id into form_submissions via the resolver
5. Update `PurposeRegistry` documentation footnote about engagement
context
6. Tests for portal-flow round-trip
### Session 4 — Frontend Timetable (5-6 days)
1. Types + Zod schemas (per §7 file structure)
2. API client modules
3. TanStack composables + Pinia store
4. Page entry (`index.vue` with day-tab state + ?day query sync)
5. Components in dependency order:
- TimeAxis, GridBg
- StageHeaderCell, StageRow
- PerformanceBlock (with CSS tokens for status, capacity icon,
B2B dots, conflict ring)
- PerformancePopover (with Form Builder advancing aggregate fetch)
- Wachtrij + WachtrijCard
- AddPerformanceDialog
- StageEditor + LineupMatrix
- EmptyDayState
6. Composables:
- `useDragOrClick` (threshold + click-suppression, single source)
- `usePointerDrag` (PointerEvents replacement of mousedown stack)
- `useTimetableMutations` (move + park + unpark + version-rollback)
- `useTimetableKeyboard` (D20 model)
7. Pure logic ports from prototype `helpers.js` to `lib/timetable/`
8. CSS-tokens file (D21)
9. Tests:
- Vitest unit on pure logic (snap, conflicts, B2B, lane preview)
- Component tests on PerformanceBlock (status rendering,
drag invokes mutation, optimistic + rollback)
- Integration: full add → drag → resize → park → delete flow with
mocked API
- Keyboard a11y: tab order, arrow nudges, escape to cancel drag
- Screen-reader test on focused block aria-labels (axe-core)
### Session 5 — Frontend Engagement Detail + Portal (3-4 days)
1. `/events/{event}/artists/{engagement}` detail page with 6 tabs
2. Each tab as separate component (Overview, Performances, Advancing,
Contacts, CrewGuests, ActivityLog)
3. Buma + VAT live calculation in OverviewTab
4. Status timeline component with transition-history rendering
5. `/p/artist/{token}` portal pages (reuse Form Builder section
render components for Advancing form)
6. Portal-side: performance card, locatie, hospitality summary,
crew/guestlist editor, contact-organisation list
7. TrashedBanner shared component (also rendered when artist master
is `deleted_at` non-null per D27)
8. Tests:
- E2E: organizer creates engagement → opens timetable → drags →
opens detail → updates status → opens portal as tour manager →
fills advance section → returns to organizer view
- Portal-token validation tests
- Tab switching state preservation
- Trashed-banner appears when artist soft-deleted, disappears on restore
### Session 6 — Frontend Master Artist Library (2-3 days)
1. `/organisation/artists` list page:
- Vuetify VDataTable with pagination + search by name/slug + filter
sidebar (genre multi-select, agent_company, has_upcoming_engagements)
- Sort: name (default), last_engagement_at desc, first_engagement_at
- Action column: edit, soft-delete (with confirm dialog), restore (when trashed)
- "Toon verwijderde" toggle in header (default off)
- "+ Nieuwe artiest" button → AddArtistDialog
2. `/organisation/artists/{artist}` detail page with 4 tabs:
- **Overview:** master fields (name, default_genre, default_draw,
star_rating, home_base_country, agent_company, notes); inline
edit; TrashedBanner if soft-deleted
- **Engagements:** cross-event history table — event name + dates +
booking_status + fee_amount + performance count + link to
engagement detail; sortable; future engagements visually
distinguished from past
- **Contacts:** master `artist_contacts` CRUD inline editing
- **Activity log:** Spatie activity log filtered by Artist subject
3. AddArtistDialog (also reusable inline from "+ Performance" flow
when booker types a new act name in the modal)
4. RestoreArtistDialog with confirm + summary of "{N} historische
boekingen, {M} toekomstige boekingen worden hersteld"
5. TrashedBanner shared component (also imported by Session 5)
6. Tests:
- Search + filter combinations
- Soft-delete flow: confirm dialog, banner appears on engagement views
- Restore flow: banner disappears
- Trashed artist hidden from AddPerformanceDialog dropdown
- Cannot create new engagement for trashed artist (modal disables)
- Trashed artist visible in EngagementHistoryTable on master detail
- Cross-event analytics correctness
## 13. Test strategy
### 13.1 Backend coverage targets
- 100% on `LaneCascadeService` (the D18 transactional algorithm)
- 100% on `ArtistEngagementService::transition` (the 9-state machine)
- 100% on Buma calculation derivation
- All policy methods (positive + negative)
- All endpoints (success + 4 error cases each)
Specific scenarios:
- Two concurrent move requests on same performance → second returns 409
- Move-with-cascade where cascade hits a `lane=9` block (max boundary)
- Cascade-bump on a busy stage where 5+ blocks need bumping
- Option expiry job with mixed expired/active/no-option engagements
- Stage-day matrix replace with orphan-performance handling
- Form Builder section submit via portal_token with engagement context
### 13.2 Frontend coverage
- Pure: snap math, conflict detection, B2B detection, lane preview,
capacity warn, day-filter
- Component: PerformanceBlock renders correctly per booking_status,
shows version-conflict toast, optimistic rollback works
- Integration: full add → drag → edit → delete flow with mocked API
- Keyboard a11y: focused block + arrow nudges; escape cancels move
- Screen reader: aria-label on blocks; aria-live on status changes
- Visual: snapshot tests on per-status block rendering
### 13.3 E2E (Playwright)
- Booker workflow: create engagement → schedule performance → drag
to new time → open popover → change status to Contracted → see
fee-required validation
- Tour manager workflow: receive portal link → open advance form →
fill technical rider section → submit → see updated advancing %
in organizer view (after polling)
- Concurrency: two browsers, same timetable, simultaneous drags
on different blocks → no data loss
## 14. Dependencies
### 14.1 Hard
- ARCH-09 (Artist model) — resolved by Session 1
- New permission `events.manage_program` and role `program_manager` —
Session 2 prerequisite
- New permission `organisations.manage_artists` — Session 2
### 14.2 Soft
- ART-15 (realtime broadcasts) — would enable multi-user live update
but version conflict (D14) covers correctness in v1
- ART-04 (tech-rider parsing) — would deepen capacity warning;
not blocking
- Notification framework (planned post-Accreditation) — needed for
option-expiry email + advance-due reminders (v1 sends via existing
EmailService directly)
### 14.3 None
- WS-3 (SPA merge) — already complete (verified 2026-05-08)
- Accreditation Engine — fully independent (auto-cascade from
Confirmed status to accreditation reservations is a v2 nice-to-have)
- Briefings module — independent
## 15. Cross-references
- `dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md` (commit
`a57437a`) — exhaustive prototype reference for UX/algorithm details
- `dev-docs/SCHEMA.md` — will be updated by Session 1
- `dev-docs/ARCH-FORM-BUILDER.md` §3.2.5, §17.3 — will be footnoted
by Session 3
- `dev-docs/AUTH_ARCHITECTURE.md` §6 — portal-token flow already in
place
- `dev-docs/ARCH-BINDINGS.md` v1.2 — for the Form Builder integration
invariants
- `BACKLOG.md` — closing ARCH-09, ART-02, ART-03; opening ART-04
through ART-24, FIN-01, FIN-02
## 16. Glossary
- **Artist (master)** — org-level record describing a performer; one per
organisation per real-world act. Persists across events.
- **ArtistEngagement** — per-event booking record; owns deal info,
status, advancing, portal_token. Multiple per artist over time.
- **Performance** — scheduled show on a stage at a time, belonging to
an engagement. 1:N with engagement.
- **Wachtrij** — runtime view of performances with `stage_id IS NULL`;
not a separate table.
- **Show host** — the event a performance hangs on: a sub-event for
festivals/series, or the flat event itself.
- **Lane** — within-stage vertical slot for parallel performances.
0-indexed.
- **Cascade-bump** — algorithm that pushes existing blocks down a
lane when a new block is dropped on top. Server-transactional.
- **Buma** — Dutch rights-collection society. 7% of fee, with three
handling modes (organisation / booking_agency / not_applicable).
- **Advancing** — the process of collecting performer data (rider,
hospitality, technical needs) via the artist_advance Form Builder
purpose, section by section.
---
End of RFC v0.2. This document is the authoritative spec for the
Artist Timetable & Engagement module across **6 implementation
sessions, 22-26 days total**. Implementation prompts will reference
specific decisions (e.g. "per RFC v0.2 D18") rather than restating
them. Frontend prompts (Sessions 4, 5, 6) additionally require the
prototype + audit doc as authoritative UX/UI input — see §12.0.