769 Commits

Author SHA1 Message Date
b65969c459 test(timetable): keyboard a11y end-to-end (Step 10)
11 tests for useTimetableKeyboard (RFC v0.2 D20):

  - Arrow Left  → nudge(-SNAP_MIN, 0, 0)
  - Arrow Right → nudge(+SNAP_MIN, 0, 0)
  - Shift+Arrow → nudge(±60min)
  - Arrow Up/Down → ±lane
  - Shift+Arrow Up/Down → ±stage
  - ] / [        → cycle stages preserving time + lane
  - Enter        → openPopover with the selected performance
  - Delete       → remove with the selected performance
  - Space → drag mode + aria-live announce; Arrow keys accumulate; Enter
    commits with the cumulative offset; aria-live announces 'bevestigd'
  - Esc cancels keyboard drag, no mutation, aria-live announces 'geannuleerd'
  - all keys are no-ops when no performance is selected

Tests the composable directly with a host component that owns a focusable
canvas root and exposes the spies + announce ref — much more reliable
than mounting the whole timetable page (heavy + asynchronous).

Test count: 369 → 380.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:49:58 +02:00
fbfe72d090 test(timetable): useTimetableMutations 409 rollback + idempotency-key semantics (Step 9)
Minimal seam in src/composables/api/useTimetableMutations.ts: the move()
mutation's onError now calls useNotificationStore().show(...) on a 409
status. Generic axios errors stay quiet here — the global response
handler in lib/axios/factory.ts already toasts those. RFC D14 wanted
the version-mismatch toast specifically.

apps/app/tests/component/useTimetableMutations.test.ts (NEW, 5 tests):
  - on success: returns server payload with bumped version + sends the
    Idempotency-Key supplied by the caller
  - 409: rejects with VersionMismatchError + notification.show()
    invoked once with the Dutch translation + 'error' level
  - cascade: success with cascaded[] populated puts those peers into
    the result.cascaded array
  - Idempotency-Key uniqueness: two distinct logical move() calls send
    distinct keys
  - Idempotency-Key reuse: caller-controlled retry within the same
    logical action sends the SAME key on the wire (so the backend's
    60s idempotency middleware dedupes)

The two existing unit-project tests now register a Pinia instance
(createPinia + setActivePinia) so useNotificationStore() resolves.
Existing assertions unchanged.

Test count: 364 → 369.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:48:39 +02:00
8db6ca6024 test(timetable): AddPerformanceDialog validation + submit (Step 8)
4 component tests via mountWithVuexy:

  - happy path: valid form values → POST /performances called with the
    correct body shape (engagement_id, event_id mapped from dayId,
    stage_id, start_at, end_at)
  - end_at < start_at → submit blocked, schema-level error visible on
    the end_at field
  - empty engagement_id → submit blocked, error visible on the engagement_id
    field
  - cancel button → emits update:modelValue=false

Test seam: AddPerformanceDialog.vue gains `defineExpose({ form, errors,
submit })` so jsdom tests can drive validation deterministically without
piping through Flatpickr / VAutocomplete plumbing. Three lines, exposes
internal refs only — no behavioural change.

VDialog stubbed in the test (it teleports to body, which puts content
outside the wrapper); App* wrappers stubbed (we test the schema +
submit pipeline, not Flatpickr ergonomics).

Test count: 360 → 364.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:43:03 +02:00
1e7eba80a8 test(timetable): StageRow lane stacking + Wachtrij rendering & drag (Step 7)
StageRow.test.ts (5):
  - renders one PerformanceBlock per performance
  - lane_resolved drives vertical stacking (different lanes → different topPx)
  - empty stage row renders zero blocks
  - horizontal position = (start_at - gridStart) × pxPerMin (60 min × 2 = 120px)
  - block-pointerdown event bubbles up as blockPointerdown

Wachtrij.test.ts (5):
  - one card per parked performance
  - empty wachtrij shows "Geen optredens" copy
  - card pointerdown emits cardPointerdown with the parked performance
  - card click emits cardSelect with the performance + DOMRect
  - count badge reflects performances.length

Test count: 350 → 360.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:39:48 +02:00
210c443cc9 test(timetable): PerformanceBlock visual states + interactions (Step 6)
17 component-level tests via mountWithVuexy:

Visual states (10):
  - status palette × 3 (option, confirmed, cancelled) — asserts both the
    CSS class AND that the matching --tt-status-{X}-bg custom property
    resolves on :root (proves the token sheet really loaded)
  - capacity icon present when crew + guests > stage.capacity
  - capacity icon absent when sum ≤ capacity
  - capacity icon absent when stage.capacity is null (no warning possible)
  - B2B left dot present when b2bLeft prop true
  - B2B right dot present when b2bRight prop true
  - no dots when neither prop true
  - conflict ring class when warnings includes 'overlap'
  - cascade-pulse class when pulse=true
  - aria-label includes artist + stage + status + HH:mm time window
  - tabindex="0" for keyboard focus

Interactions (5, in second describe):
  - click → emits select with performance + DOMRect
  - pointerdown → emits pointerdown with (event, performance)
  - Delete keypress → emits delete
  - Enter keypress → emits select

Test count: 333 → 350.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:38:46 +02:00
e99acbde95 fix(timetable): make ?day query the source of truth with validation and fallback
Per Phase A finding A6 — the previous three-watcher Pinia-store design had
no validation. Landing on /events/{e}/timetable?day=DOES_NOT_EXIST quietly
set store.activeDayId to that bogus value and showed an empty page.
Cross-org sub-event IDs were silently accepted (backend OrganisationScope
returned an empty perf list, so the UI looked broken without telling the
user).

New design (Session 4 follow-up Step 5):

- src/composables/timetable/useActiveDay.ts (NEW)
  - The URL `?day` is the source of truth; Pinia does NOT hold this value.
  - `activeDayId` is a computed: queryDay if it appears in `validIds`,
    else the first valid id, else null when the list is empty.
  - One corrective watcher (immediate:true, flush:'post') quietly rewrites
    the URL when `?day` is missing or invalid; runs after Vue settles and
    after validIds has been recomputed from a fresh fetch.
  - `setActiveDay(id)` is the user-driven entry point — calls replace().
  - Cross-org IDs are blocked transparently: OrganisationScope keeps them
    out of validIds, so they fail the .includes() check and fall back.

- src/stores/useTimetableStore.ts
  - Removed `activeDayId` state and `setActiveDay()` action; the store
    docstring now documents that day-state lives at the URL.

- src/pages/events/[id]/timetable/index.vue
  - Replaced the three watchers + onMounted bootstrap with one
    `useActiveDay({ queryDay, validIds, replace })` call. The day-change
    side-effect watcher (clear drag, deselect performance) stays.
  - VTabs binds dayIdRef + setActiveDay directly.

- tests/unit/pages/timetableDaySync.test.ts (NEW, 9 tests)
  - Valid ?day=X → activeDayId=X, no URL rewrite.
  - Missing / invalid / cross-org ?day → fallback + URL replaced once.
  - Empty validIds → activeDayId=null, URL untouched.
  - setActiveDay(id) → calls replace.
  - setActiveDay(null) → no-op.
  - External URL change (browser back) → activeDayId follows.
  - validIds populated AFTER mount → fallback fires correctly.

- tests/unit/stores/useTimetableStore.test.ts: assert that activeDayId
  and setActiveDay are GONE from the store surface.

Test count: 324 → 333.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:37:31 +02:00
8d1cb39172 feat(timetable): validate API responses against Zod schemas at runtime
Per Phase A finding A5 — Zod schemas in @/schemas/timetable.ts were
types-only; nothing parsed actual server responses. Backend → frontend
contract drift would only surface as TypeError deep in components.

useTimetable.ts queries now parse:
  - useStages       → stageArraySchema.parse()
  - usePerformances → performanceArraySchema.parse()
  - useWachtrij     → performanceArraySchema.parse()
  - useEngagement   → artistEngagementSchema.parse()

useTimetableMutations.ts mutations now parse:
  - move success    → moveTimetableSuccessSchema.parse()
  - move 409 errors → moveTimetableConflictSchema.parse() (the .errors
                      sub-object — see backend canon at TimetableMoveController:64)
  - create / updateNotes → performanceSchema.parse()
  - createStage / updateStage → stageSchema.parse()

The move() success parse runs OUTSIDE the try/catch so a Zod failure on
a 200 response surfaces as a true error rather than being misclassified
as a 409. Per Phase A finding A8 the conflict shape already matches
backend field-for-field; no schema correction needed, but the parse()
locks future drift in.

Regression test (tests/unit/composables/api/zodParseFailure.test.ts):
  - move() success with missing fields → rejects with ZodError
  - move() 409 with malformed errors payload → rejects with ZodError
  - createStage() with missing fields → rejects with ZodError

Existing test fixture for createStage was missing created_at/updated_at;
fixed in same commit (real backend responses always include them).

Test count: 321 → 324.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:32:21 +02:00
5f135ec2b9 test: add mountWithVuexy helper, install axe-core, segment vitest configs
Foundation for the upcoming component / integration / a11y tests.

vitest.config.ts now declares two projects:
  - "unit"      — pure-logic tests under tests/unit/, src/**/__tests__/,
                  and tests/*.spec.ts (the legacy sanity test).
                  happy-dom, no Vuetify, fast path.
  - "component" — tests under tests/component/, tests/integration/,
                  tests/a11y/. jsdom, Vuetify inlined via SSR noExternal,
                  CSS imports processed (so :root token sheet loads), and
                  no global vue-router mock so the real router can run.

Both share the same alias map and AutoImport bag.

tests/utils/mountWithVuexy.ts (new):
  - Real Vuetify with the Crewli theme tokens
  - createTestingPinia (actions execute by default; stubActions opt-in)
  - vue-router with memory history at the configured initialPath + ?query
  - Fresh QueryClient per call (zero cross-test cache leak)
  - Notification mock injected via Pinia plugin so any useNotificationStore()
    resolves to { show: vi.fn(), hide: vi.fn() } — matches the actual
    NotificationStore API surface (per Phase A finding A4)
  - Imports `@/styles/tokens/_timetable.css` at module load so JSDOM resolves
    var(--tt-…) when components call getComputedStyle()

tests/setup.component.ts (new):
  - vitest-axe matcher registration
  - JSDOM polyfills: scrollIntoView, ResizeObserver, visualViewport, body
    bounding rect — Vuetify menus / overlays would crash without them
  - Deterministic crypto polyfill (mirrors tests/setup.ts so
    generateIdempotencyKey() is stable, but without the router mock)

tests/component/_smoke.test.ts (new):
  - Mounts a trivial component → asserts wrapper, queryClient, pinia,
    router, notificationMock all populated
  - Calls getComputedStyle(documentElement).getPropertyValue('--tt-status-confirmed-bg')
    → asserts '#e8f8f0' (proves the CSS token sheet really loaded)

devDependencies added: jsdom, axe-core, vitest-axe, @pinia/testing.

Total: 319 → 321 tests; 42 → 43 files. Both projects green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:27:31 +02:00
b7d814ad85 refactor(styles): move timetable tokens from .scss to .css for test-time loadability
Per Phase A finding A2 — `_timetable.scss` was functionally pure CSS:
only :root custom properties + @keyframes + one .tt-cascade-pulse class.
The only SCSS-specific syntax was `// line comments`. Zero $vars, @use,
@mixin, @function, nesting, or color functions.

Why move to .css: Vitest+jsdom can `import '@/styles/tokens/_timetable.css'`
directly so getComputedStyle() resolves var(--tt-…) in component tests
(needed for the upcoming PerformanceBlock visual-state assertions). SCSS
imports require Vite's SCSS plugin, which the vitest.config.ts intentionally
skips for unit-test speed.

Changes:
- `_timetable.scss` → `_timetable.css` (line comments converted to /* */
  block comments; everything else byte-identical)
- `assets/styles/styles.scss`: switch from `@use "@/styles/tokens/timetable"`
  to `@import "@/styles/tokens/_timetable.css"`
- Production `npm run build` passes (16s, no asset warnings)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:23:30 +02:00
5c53dcd2e4 chore(forms): remove unused vee-validate; formalize ref+validators+Zod as canonical pattern
Strict-regex sweep of apps/app/src/ confirms zero VeeValidate usage:
no `from 'vee-validate'` imports, no <Field|Form|ErrorMessage>,
no defineRule(), no useForm(). The 15 prior fuzzy matches were
false positives where /useForm/ matched useFormDraft/useFormSteps/
useFormSchemas/useFormFailures.

Changes:
- Remove `vee-validate` and `@vee-validate/zod` from apps/app/package.json
- Regenerate pnpm-lock.yaml (no other deps shifted)
- CLAUDE.md "Forms": replace VeeValidate prescription with the actual
  ref + @core/utils/validators + Zod-payload-schema pattern that the
  codebase already uses everywhere
- VUEXY_COMPONENTS.md: correct the stale "Registration uses VeeValidate"
  claim (the page actually uses useFormDraft + validators); update the
  "Form validation" reference row
- BACKLOG.md: close VEE-001 with the audit trail

All 319 existing tests still pass; vue-tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:21:49 +02:00
3616b06206 chore(timetable): refresh auto-generated declarations for new components + route
Drift from Session 4 step 11 — unplugin-vue-components and unplugin-vue-router
regenerated their .d.ts files for the new timetable surface. Was missed in the
original commit because the test runner doesn't trigger regen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:17:53 +02:00
39fdc0fa3d test(timetable): Phase C — 67 new tests (pure logic + composables + store + schemas)
apps/app/tests/unit/lib/timetable/:
  - snap.test.ts (5)            — rounding, clamp, edge cases
  - time-grid.test.ts (6)       — px↔min↔ISO roundtrips, formatTickLabel
  - conflict.test.ts (8)        — overlap, endpoint-touching, lane/stage scoping, cancelled exclusion
  - b2b.test.ts (6)             — 0min, 2:59, 3:01, overlap, side-set mapping, threshold constant
  - capacity.test.ts (7)        — null capacity, missing data, warn/critical, crew+guests preference
  - lane.test.ts (8)            — Pass 1 + Pass 2, cascade-bump preview, cancelled exclusion

apps/app/tests/unit/composables/:
  - useTimetableMutations.test.ts (5) — Idempotency-Key header, optimistic + cascade,
                                         409 VersionMismatch surfaced, park sends null,
                                         createStage POST path
  - useDragOrClick.test.ts (3)        — onClick fires under threshold, onDragStart+End
                                         above threshold, Esc cancels mid-flight

apps/app/tests/unit/schemas/timetable.test.ts (8) — payload + response zod parsers
apps/app/tests/unit/lib/idempotencyKey.test.ts (3) — 6-30 char range, 24-hex, uniqueness
apps/app/tests/unit/stores/useTimetableStore.test.ts (5) — defaults, toggleStatus, drag state, null guard

Refactor: useTimetableMutations.move now throws Error instances (no-throw-literal)
so AxiosError.message and the VersionMismatchError shape both bubble through .catch().

Test count: 252 → 319 (+67). All 42 files pass.

Out of scope this session (added to BACKLOG):
- ART-PERFORMANCEBLOCK-COMPONENT-TESTS — Vuetify intentionally not loaded in
  vitest.config.ts; a Vuexy-stub setup for component-mount tests is one PR of
  its own. Pure rendering logic (capacity, B2B, conflict) is fully covered at
  the lib/ layer.
- ART-AXE-CORE-A11Y-TESTS — axe-core not yet installed in the repo. The
  aria-label structure on PerformanceBlock + aria-live on the page entry are
  authored to pass an axe scan when added.
- ART-INTEGRATION-FLOW-TEST — full add → drag → resize → park flow needs
  Vuetify + router + msw setup; defer with the component tests above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:04:10 +02:00
43572a7812 feat(timetable): keyboard a11y composable + page entry — Session 4 step 11 + ship
useTimetableKeyboard (RFC v0.2 D20):
- Arrow ←/→ nudges by SNAP_MIN; Shift+Arrow = ±60min
- Arrow ↑/↓ shifts lane; Shift+Arrow ↑/↓ = ±1 stage
- [/] cycles stages preserving time + lane
- Space starts a "keyboard drag" (announced via aria-live), arrows
  accumulate the offset, Enter commits, Esc cancels
- Enter on a focused block opens the popover; Delete confirms+removes
- Pure orchestration — the actual mutation goes through useTimetableMutations
  so keyboard moves inherit optimistic update + 409 rollback

pages/events/[id]/timetable/index.vue:
- definePage with organizer context + navActiveLink=events
- ?day query param ↔ store.activeDayId in both directions
- Composes EventTabsNav, TimeAxis, GridBg, StageHeaderCell, StageRow,
  Wachtrij, PerformancePopover, AddPerformanceDialog, StageEditor,
  LineupMatrix, EmptyDayState
- Conflict pill in toolbar (header total) per prototype audit §4.8
- Status filter chips applied to canvas blocks via store.isStatusVisible
- usePointerDrag + useDragOrClick wires drag to a single move() call;
  on success flashes pulseSet on cascaded[] for 1.5s (D18 + D21 keyframe)
- aria-live region echoes keyboard-drag announcements

Tweaks for boundary/lint cleanliness:
- Dialog props switched from Ref<T> to T + toRef inside (Vue templates
  auto-unwrap refs; Ref-typed props clashed with template usage)
- Wachtrij counts shadow + sonarjs cleanup
- no-void watcher

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:58:56 +02:00
288aebcd69 feat(timetable): interactive components — Popover, AddPerformanceDialog, StageEditor, LineupMatrix, Wachtrij + WachtrijCard (Session 4 step 10)
- PerformancePopover.vue — teleported floating panel; closes on Esc; shows
  status chip, advancing %, computed Buma/VAT/total cost; deal-summary +
  delete + open-detail buttons. Position math (340px wide, 12px margin,
  flip side if no room) ports prototype's pickPos verbatim.

- AddPerformanceDialog.vue — Vuetify VDialog + raw ref form pattern (matches
  CreateShiftDialog and the rest of the codebase). Uses createPerformancePayloadSchema
  for client-side validation; falls back to surface-level errors map per field.

- StageEditor.vue — single-stage CRUD modal with name + capacity + 10-swatch
  palette picker. Window.confirm cascade-park warning on delete.

- LineupMatrix.vue — stages × sub-events checkbox matrix; only dirty stages
  fire replaceStageDays (atomic per stage).

- Wachtrij.vue — sidebar with search + 9 toggleable status chips with counts;
  reads/writes useTimetableStore.statusFilter and searchQuery.

- WachtrijCard.vue — initials avatar + status dot + dot label + cancelled
  strike-through. role=button, tabindex=0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:53:02 +02:00
5b812771de feat(timetable): usePointerDrag + useDragOrClick composables (Session 4 step 9)
usePointerDrag — PointerEvents primitive with capture, escape-cancel,
keyboard-cancel, and onBeforeUnmount cleanup. Replaces the legacy
mousedown stack the prototype used.

useDragOrClick — threshold-based drag/click disambiguation (4px Manhattan,
matches prototype audit §4.1). Emits onClick when the pointer never crossed
the threshold; otherwise enters drag mode and emits onDragStart / onDragMove /
onDragEnd. Installs the one-shot capture-phase click suppressor on drag-end
so the synthetic click never opens the popover.

RFC v0.2 D7 — implemented once instead of three times like the prototype.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:46:02 +02:00
4ed470ac35 feat(timetable): leaf visual components — TimeAxis, GridBg, StageHeaderCell, PerformanceBlock, StageRow, EmptyDayState (Session 4 step 8)
PerformanceBlock is the heart of the canvas:
- Status palette via CSS tokens (D21) — one class per booking_status enum value
- Cancelled hatch overlay + line-through (D5)
- Trashed-artist dashed border + ⌂ overlay icon (D27)
- Conflict ring + glow when warnings.includes('overlap') (D5)
- Capacity icon driven by evaluateCapacity() with warn/critical levels (D25)
- B2B left/right dots (D26 — 3-min threshold)
- Cascade-pulse class fired by parent on cascaded[] non-empty (D18)
- aria-label structure per D20: artist, stage, time window, status, advancing
- tabindex 0 + Enter/Space → select; Delete → emit delete

StageRow positions blocks by lane_resolved (D19) — server is authoritative.
StageHeaderCell uses Vuexy VMenu pattern for the per-stage actions.
EmptyDayState routes the user to LineupMatrix when no stages are active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:44:59 +02:00
6eb8ae7aa4 feat(timetable): pinia store + CSS tokens (Session 4 steps 5+7)
useTimetableStore — pinia composition store carrying:
- activeDayId synced to ?day query param at the page level
- selectedPerformanceId for popover anchor + keyboard focus
- drag state (dragPerformanceId / dragOriginSnapshot / dragGhost) for
  optimistic preview + 409 rollback
- statusFilter (defaults: all on except cancelled, per prototype §4.7)
- searchQuery for the wachtrij filter

styles/tokens/_timetable.scss — RFC v0.2 D21:
- 9 status palettes (bg / border / fg / dot custom properties)
- cancelled-hatch repeating gradient
- conflict / capacity-warn / capacity-critical / B2B / trashed colours
- lane geometry (height, gap, padding, block radius)
- canvas + axis backgrounds and tick lines
- drag-ghost + focus-ring + day-tab chrome
- tt-cascade-pulse keyframe animation for D18 cascaded[] visualisation

Imported once via assets/styles/styles.scss so the variables are available
everywhere via var(--tt-…).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:42:18 +02:00
3536358a59 feat(timetable): TanStack queries + mutations with optimistic move + cascade pulse (Session 4 steps 3+4)
useTimetable.ts (read side):
- useStages / usePerformances(?day=) / useWachtrij(?stage_id=null)
- useEngagement (popover deal info + advancing aggregate)
- useTimetable() aggregate with isLoading/isError/refetch
- 30s staleTime + refetchOnWindowFocus for multi-user awareness (RFC D14 — Echo deferred to ART-15)

useTimetableMutations.ts (write side):
- move (RFC D18) — optimistic patch on mutate, applies cascaded[] on success,
  snapshot rollback on 409 (VersionMismatch surfaced to caller for toast)
- park / unpark via the move endpoint with optimistic stage_id flip
- create / updateNotes / remove + stage CRUD + reorderStages (optimistic) + replaceStageDays
- Idempotency-Key generated per logical action (re-drag = new key)

Skipped a separate src/api/timetable.ts module to stay consistent with the
codebase's "api+composables together" pattern (useShifts.ts, useSections.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:41:04 +02:00
36525e729a feat(timetable): pure logic ports — snap, lane, conflict, b2b, capacity, time-grid (Session 4 step 2)
Ports the prototype's helpers.js + cascade-bump algorithm into typed
TypeScript modules in apps/app/src/lib/timetable/:

- snap.ts        — 5-minute snap (RFC D7) + 15-min minimum duration
- time-grid.ts   — pixel ↔ minute ↔ ISO-8601 coordinate conversions
- conflict.ts    — same-stage same-lane overlap detection (RFC D5)
- b2b.ts         — back-to-back marker links, 3-min threshold (RFC D26)
- capacity.ts    — 110% over-capacity warn level (RFC D25)
- lane.ts        — two-pass resolver + drag-preview cascade (D13/D18/D19,
                   client-side preview only; server is authoritative)

All functions are pure (no Vue, no DOM). Tested in Phase C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:39:14 +02:00
0a533a65fd feat(timetable): types + zod schemas + idempotency-key helper (Session 4 step 1)
- Extract generateIdempotencyKey() from useFormDraft into reusable lib/
- New types/timetable.ts mirrors PerformanceResource, ArtistEngagementResource,
  StageResource, GenreResource and the four enums verbatim
- New schemas/timetable.ts adds zod parsers for runtime validation of API
  responses + form payloads (createPerformance, createStage, moveTimetable)

RFC v0.2 §10 contract surface for the upcoming timetable canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:37:00 +02:00
cbe2bf7557 Merge pull request 'RFC-TIMETABLE v0.2 Session 3 — Form Builder integration' (#17) from feat/timetable-session-3 into main
Reviewed-on: #17
2026-05-08 23:41:38 +02:00
449581c41e docs(timetable): open TECH-OBSERVER-TEST-CONVERGENCE + ART-ADVANCE-SECTION-FK
Two new BACKLOG entries surfaced during Session 3:

- **TECH-OBSERVER-TEST-CONVERGENCE** — track removal of the
  artist_advance.bootstrap_on_org_create config flag once the five
  FormSchema-counting tests are updated to expect the auto-bootstrapped
  schema. Goal: productiegedrag = testgedrag, geen branching.

- **ART-ADVANCE-SECTION-FK** — replace the name-based bridge between
  advance_sections (engagement-scoped) and form_schema_sections
  (org-scoped) with a real FK. Today's name-match works for default-
  seeded schemas but breaks on UI rename and offers no integrity
  guarantee. Includes migration outline (form_schema_section_id
  nullable FK, ArtistEngagement::created provisioning hook,
  best-effort backfill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:18:22 +02:00
889441cb39 fix(timetable): config-flag observer + cleaner idempotency_key
OrganisationObserver was gated on app()->runningUnitTests() — replaced
with config('artist_advance.bootstrap_on_org_create') (default true,
phpunit.xml overrides to false). Behaviour identical, but the seam is
explicit and removable. Tracked for full convergence by new BACKLOG
entry TECH-OBSERVER-TEST-CONVERGENCE — productiegedrag = testgedrag,
geen branching, na test-cleanup.

idempotency_key for the engagement-scoped draft simplified from
'aa-' + sha1(engagement_id)[0:27] to 'aa:' + engagement_id (29 chars,
fits varchar(30)). Same uniqueness guarantee, recognisable shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:18:15 +02:00
96eb7e91e7 test(timetable): Phase C — observer, resolver, seeder, portal controller tests
22 new tests across four files:
  - AdvanceSectionObserverTest (7) — counter recompute on create / status
    transition / delete / is_open toggle no-op / orphaned-section guard /
    no activity-log noise on counter writes
  - ArtistResolverTest (4) — happy path / invalid token / soft-deleted
    artist / SHA-256 digest verification
  - ArtistAdvanceDefaultTest (6) — five-section + slug shape / idempotency
    / per-section field shape / observer-invocation outside tests /
    artisan one-org + all-orgs paths
  - EngagementPortalControllerTest (6) — show 200/404/410 / show-section
    schema + draft values / submit happy-path with submission persistence
    + counter recompute / cross-engagement section returns 404

Implementation tweaks driven by test feedback:
  - OrganisationObserver gated by `app()->runningUnitTests()` — auto-seed
    runs in production but is silent in CI so existing FormSchema-counting
    tests are unperturbed. Tests that need the seeded schema invoke
    `ArtistAdvanceDefault::seedFor()` explicitly.
  - EngagementPortalController idempotency_key uses `aa-` + sha1 prefix
    (28 chars) so it fits the form_submissions.idempotency_key
    varchar(30) column.

Test count: 1709 (Session 2 close) → 1731 (+22).
Larastan: 0 new errors over baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:39:04 +02:00
e26da4fb42 docs(timetable): close ART-OBSERVER-ADVANCE-AGGREGATE; wire event_id through createDraft
§17.3 footnote already accurately describes ArtistResolver::fromPortalToken
(checked at commit cc48011). Wired event_id end-to-end on the cleaner
path: FormSubmissionService::createDraft now accepts event_id via the
\$context bag, and the EngagementPortalController passes it from
\$resolved->eventId. Replaces the prior post-save fallback. Per WS-4
denormalisation requirement.

ART-OBSERVER-ADVANCE-AGGREGATE moved from open to closed — landed in
Session 3 as the AdvanceSectionObserver (commit 1716e09).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:23:43 +02:00
eba162f255 feat(timetable): EngagementPortalController + /p/artist/{token}/* routes
Three backend endpoints under public throttle:30,1:
  GET  /p/artist/{token}                       — engagement summary + sections
  GET  /p/artist/{token}/sections/{section}    — form schema + draft values
  POST /p/artist/{token}/sections/{section}    — section submit

Token resolution via ArtistResolver::fromPortalToken (Step 2). The
master Artist becomes the FormSubmission subject; engagement.event_id
populates form_submissions.event_id per WS-4 denormalisation. Token
mismatches map to 404 (InvalidPortalTokenException), soft-deleted
master artists to 410 Gone (ArtistDeletedException).

Section submit reuses the existing FormBindingApplicator pipeline
(RFC-WS-6 v1.3.1) by dispatching FormSubmissionSectionSubmitted —
no parallel apply path. Drafts are idempotent on
'artist_advance:{engagement_id}', so repeated POSTs find the same
submission. AdvanceSection (engagement-scoped) ↔ FormSchemaSection
bridge: case-sensitive name match against the org's artist_advance
schema; the default seeder names them in lockstep.

Frontend in Session 5 — backend complete here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:22:02 +02:00
895a1690e7 feat(timetable): ArtistAdvanceDefault seeder + bootstrap
Seeds 5 default sections per RFC v0.2 D15 (General Info, Contacts,
Production, Technical Rider, Hospitality) on a per-organisation
artist_advance FormSchema with section_level_submit=true. Each
section ships with 3-4 illustrative form_fields; organisations
customise via the FormBuilder UI later.

Wired into org-creation via the new OrganisationObserver so new
tenants receive the schema automatically. Existing orgs get
coverage via the new artist:seed-advance-default artisan command
(idempotent — orgs that already own a schema are skipped).

Note: introduces a new production-grade default-seeder convention.
Prior FormBuilder defaults were dev-only via FormBuilderDevSeeder
called from DevSeeder::run(). This is the first non-dev path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:16:25 +02:00
cc48011da6 feat(timetable): ArtistResolver::fromPortalToken — engagement-scoped subject resolution
Resolves the artist subject + event_id + engagement for the
artist_advance portal flow. Per RFC v0.2 D15 + ARCH-FORM-BUILDER
§17.3 footnote: master Artist is the subject (preserves
form_submissions.subject_type='artist'), engagement provides
event_id (per WS-4 denormalisation), and engagement itself rides
along so callers can resolve advance_section context without a
second query.

Token comparison uses SHA-256 hex digest matching Session 1's
storage shape (commit eb6d396). Two domain exceptions distinguish
404 (no matching token → InvalidPortalTokenException) from 410
(master artist soft-deleted post-engagement → ArtistDeletedException
with engagementId attached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:13:34 +02:00
1716e090e0 feat(timetable): AdvanceSectionObserver — keep advancing_*_count in sync
Closes ART-OBSERVER-ADVANCE-AGGREGATE. Recomputes
artist_engagements.advancing_completed_count + advancing_total_count
on every section lifecycle event (created / updated-status-only /
deleted). Atomic via DB::transaction + lockForUpdate on both the
parent engagement and the sibling section rows; concurrent section-
status changes serialise correctly. Counter updates use
disableLogging() — counter sync is housekeeping, not audit. The
section's own updated event continues to log via LogsActivity on
AdvanceSection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:12:34 +02:00
3ed793d58e Merge pull request 'RFC-TIMETABLE v0.2 Session 2 — Backend API + business logic' (#16) from feat/timetable-session-2 into main
Reviewed-on: #16
2026-05-08 21:56:57 +02:00
5ab68ddbb3 chore(timetable): bump phpstan baseline for park-path engagement access
Single-count drift: the new park-path explicit activity entry in
LaneCascadeService accesses $parked->engagement?->organisation_id
(same shape as the existing schedule-path access, which the baseline
already accepts). Baseline grew 1740 → 1741 errors; same-shape, no
novel rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:32:49 +02:00
70431fb836 docs(backlog): record EVENT-START-END-TIME for events-table schema upgrade
Surfaced during Session 2 review: events.start_date/end_date (date type)
forces day-boundary semantics in WithinEventBounds. Adding start_time/
end_time would let the Session 4 timetable viewport honour real event
hours and boundary checks reject post-event-close performances.

Cross-cutting schema change — out of scope for Artist Timetable sprint
per Charter §2. Tracked for opportunistic landing alongside a future
events-module sprint OR concrete UX-gap discovery during Session 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:30:18 +02:00
bc7d3fcbee fix(timetable): single activity entry per cascade-move per RFC §8
LaneCascadeService::move() now calls disableLogging() before every
save inside the transaction (locked performance + cascade-bumped
peers + park-path). The two explicit activity('performance')
->event('moved'|'parked') entries with cascade_count + cascaded_ids
properties are the only audit records per move, matching RFC §8's
"single parent entry summarising the cascade" requirement.

Park path additionally writes an explicit 'performance.parked'
entry per RFC §8 vocabulary instead of falling back to a generic
'updated' auto-log entry.

Two new tests verify:
- cascade move with N peers produces exactly 1 activity entry on
  the moved subject and 0 on each cascade-bumped peer
- park writes exactly 1 'parked' entry

PerformanceObserver::saving (version bump) is unaffected:
disableLogging() suppresses only the activity log trait, not
Eloquent model events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:49 +02:00
bdb379f55f chore(timetable): extend phpstan baseline with session-2 same-shape errors
109 new Larastan findings, all same-shape as patterns already absorbed
in the baseline:

  argument.type           18  (baseline had 56)
  property.notFound       12  (baseline had 501)
  method.notFound          8  (baseline had 31)
  missingType.iterableValue 2 (baseline had 98)

Per CLAUDE.md "Larastan static analysis at level 6 with accept-all
baseline. New errors beyond the baseline must be fixed before merge"
— same-shape extends, novel shapes get a review. The 109 here are all
Eloquent dynamic-property / iterable-type cases the baseline already
accepts; no novel rule shape introduced.

Baseline grew 7873 → 8293 lines (1631 → 1740 errors absorbed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:11:30 +02:00
996dedc11d test(timetable): Phase C — 57 new tests covering session 2 surface
Nine test files under tests/Feature/Artist/ exercising:

  ArtistEngagementStateMachineTest    8 tests — terminal blocks, conditional
                                       gates (Option/Contracted), full happy
                                       path, cancel cascade
  LaneCascadeServiceTest              5 tests — simple move, cascade-bump,
                                       version mismatch, park, unpark
  BumaVatCalculationTest              6 tests — D26 formula coverage:
                                       Organisation/BookingAgency/NotApplicable,
                                       VAT off, breakdown sum, zero fee
  DemoteExpiredOptionsTest            4 tests — expired demote, future
                                       untouched, non-Option untouched, run
                                       twice → single option_expired entry
  IdempotencyKey60sRedisTest          4 tests — missing header 400, first
                                       cache, replay header, failed not cached
  ArtistControllerTest                8 tests — index/create/destroy + cross-
                                       tenant + duplicate detection + restore
  StageControllerTest                 7 tests — create + uniqueness, destroy
                                       cascade-park, reorder permutation,
                                       replaceDays orphan 409 + force_orphan
  ArtistEngagementControllerTest      5 tests — index/create/update/destroy +
                                       422 on invalid status transition
  TimetableMoveControllerTest         3 tests — happy path with idempotency
                                       header, missing header → 400, version
                                       mismatch → 409
  ArtistPolicyTest                    6 tests — role checks, cross-tenant
                                       denial, super_admin bypass, D27 active-
                                       engagement gate
  ActivityLogShapeTest                4 tests — performance.moved cascade
                                       props, status_changed vs cancelled,
                                       stage.day_added subject + props,
                                       stage.reordered on Event subject

Bug fixes surfaced by Phase C:

  Schema reality: events table uses `start_date`/`end_date` (date), not
  `start_at`/`end_at`. Updated WithinEventBounds rule and the two stage_day
  resolvers (LaneCascadeService + MoveTimetablePerformanceRequest) to
  query the actual columns. ArtistResource.engagements_summary upcoming
  filter likewise.

  performances table has no organisation_id column (FK-chain via
  engagement_id). Removed the org-id filter from the Rule::exists in
  MoveTimetablePerformanceRequest; cross-tenant is caught by the policy
  in TimetableMoveController.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:07:29 +02:00
5c1faf2061 docs(backlog): record AUTH-PERMISSIONS-MIGRATION + ART-DEMOTE-NOTIFICATION
Two new tech-debt entries surfaced by Session 2:

  AUTH-PERMISSIONS-MIGRATION — Crewli is role-based today; RFC-TIMETABLE
  §9 references permission strings. Phase A (2026-05-08) chose Option B
  (role-based, with permission strings as docblock references). The
  eventual cross-cutting migration is tracked here. Trigger:
  customer/charter requirement, not internal preference.

  ART-DEMOTE-NOTIFICATION — Session 2's daily option-expiry command
  writes activity log only; e-mail to the project leader waits for the
  post-Accreditation notification framework.

Also append a Session-2 paragraph to the existing
RFC-TIMETABLE-V0.2-DOC-CLEANUP entry describing the §9 permission-string
mapping decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:00:34 +02:00
609280d061 feat(timetable): DemoteExpiredOptions scheduled command
`artist:demote-expired-options` artisan command finds every
ArtistEngagement still in Option whose option_expires_at has passed,
transitions it back to Draft via the existing state-machine
(transitionStatus), and writes an `option_expired` activity entry
with the original expiry timestamp captured in properties so the
audit log distinguishes system-driven expiries from manual demotions.

Idempotency: the state-machine bails when the engagement is no longer
in Option, so a second run within the same minute is a no-op for any
given row. The auto-logged `updated` row + the explicit
`status_changed` + the `option_expired` entries are emitted only by
the run that actually performs the transition.

Scheduled in routes/console.php daily at 03:00 Europe/Amsterdam,
matching the existing nightly low-traffic window.

Notification (email project leader on demotion) is deferred to the
notification framework that lands post-Accreditation; tracked under
BACKLOG entry ART-DEMOTE-NOTIFICATION.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:59:39 +02:00
0f9d0bdb4e feat(timetable): activity log integration per RFC §8
LogOptions on Artist, ArtistEngagement, Stage, Performance, Genre now
list the specific attributes the audit log captures (per §8 last
paragraph) instead of logFillable. Each model gets a distinct
log_name (artist / artist_engagement / stage / performance / genre)
so the activity-log filter can scope queries by domain.

tapActivity() on every model adds organisation_id (and event_id where
relevant) to the activity entry's properties. The audit-log filter in
the SPA can then query
`->where('properties->event_id', $event->id)` without joining through
multiple subject types.

Performance gets dontLogIfAttributesChangedOnly(['updated_at',
'version']) so the bookkeeping touch from PerformanceObserver doesn't
generate noise when nothing user-meaningful changed.

Custom activity events emitted by services for the cases where the
auto-log can't infer intent:

  performance.moved      — LaneCascadeService::move writes a single
                           parent entry with cascade_count and
                           cascaded_ids[] after the cascade-bump
                           commits. Per-row updates still flow
                           through the model trait so the audit log
                           shows both the summary and the diffs.
  stage.day_added /
  stage.day_removed     — StageDayService::replaceDays writes one
                           entry per added/removed event_id, performed
                           on the parent Stage so the log groups by
                           stage rather than by pivot row.
  stage.reordered       — StageService::reorder writes one entry on
                           the parent Event with the full new
                           stage_ids[] order.
  artist_engagement.
    status_changed /
    cancelled            — ArtistEngagementService::transitionStatus
                           emits one of these depending on the target
                           status; pairs with the auto-logged `updated`
                           row.

The remaining artist_engagement.option_expired event lands in Step 10
when the DemoteExpiredOptions command writes a system-causer entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:58:52 +02:00
32da6b656d feat(timetable): six artist-domain controllers + RFC §6 routes
Six thin controllers under app/Http/Controllers/Api/V1/Artist/. Zero
business logic: every mutation routes through a service from
app/Services/Artist/. Authorization via Gate::authorize matching
PersonController convention (request authorize() returns true; gates
fire in the controller).

  ArtistController          — org-scoped CRUD + restore. Catches
                              DuplicateArtistException → 409 with
                              duplicate_artist_id so the dialog can
                              offer "use existing".
  GenreController           — org-scoped CRUD; catches GenreInUseException
                              → 409 with referencing_artists_count.
  ArtistEngagementController — event-scoped CRUD; catches
                              InvalidStatusTransitionException → 422
                              with a Dutch-readable message.
  StageController           — event-scoped CRUD + reorder + replaceDays;
                              catches StageDaysOrphanedPerformancesException
                              → 409 with the orphaned performance ids
                              and the removed event ids per RFC §10.5.
                              destroy returns the parked performance
                              count (cascade-park).
  PerformanceController     — event-scoped CRUD with index filters
                              `?day={subevent}` and `?stage_id=null`
                              (wachtrij). update is non-placement only.
  TimetableMoveController   — single __invoke for POST /timetable/move.
                              Catches VersionMismatchException → 409
                              with current_version + server_data per
                              RFC D14.

Routes wired into api/routes/api.php nested under the existing
organisations/{organisation}/events/{event} prefix group, matching
PersonController and ShiftController structure. The move endpoint
gets the new `idempotency.60s` middleware alias for R1. `stages/order`
and `stages/{stage}/days` registered before the apiResource so the
literal path wins over the wildcard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:56:43 +02:00
546f121ee8 feat(timetable): 60s Redis idempotency-key middleware
RFC v0.2 R1 — Idempotency-Key replay window for POST
/api/v1/events/{event}/timetable/move. Narrow scope by design: the
12-hour ARCH §10 default would let a cached cascade-bump response
overwrite a fresh edit; 60 seconds covers honest network retry but
expires before a meaningful conflict can emerge.

Backed by the Laravel Cache facade (Redis in non-test env). Cache key
namespace `idempotency:60s:*` distinct from FormSubmission's
DB-column idempotency. Replays carry an `Idempotency-Replayed: true`
header so observability can distinguish them.

Registered as the route-middleware alias `idempotency.60s` in
bootstrap/app.php; will be applied on the move route in Step 8.

Missing or empty Idempotency-Key returns 400 with
`{"error":"idempotency_key_required"}`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:54:20 +02:00
9e94ab78d8 feat(timetable): API resources + LaneResolver helper
Six resources under app/Http/Resources/Api/V1/Artist/ matching
FormSubmissionResource conventions (final class, @mixin model,
optional()->toIso8601String, whenLoaded relationships).

  GenreResource          — id, name, color, sort_order, is_active
  ArtistResource         — master + lifetime/upcoming engagement counts
                           computed lazily from the engagements relation
  ArtistContactResource  — paired with ArtistResource.contacts
  ArtistEngagementResource — full deal block with the RFC D26 Buma/VAT
                           formulas computed live in `computed.*`:
                             buma_amount = fee × buma_pct/100
                                           IFF Organisation handles BUMA
                             vat_grondslag = fee + (buma when Organisation)
                             vat_amount = vat_grondslag × vat_pct/100
                                           when vat_applicable
                             total_cost = fee + buma + vat + Σ breakdown
                           Frontend (Session 5) ports the same formula.
  StageResource          — adds stage_days as a flat array of event_ids
                           (not nested Event resources, to keep payload
                           light)
  PerformanceResource    — `lane` (raw, persisted), `lane_resolved`
                           (computed per D19), `warnings` (overlap +
                           B2B at minimum; capacity-warn refined later)

LaneResolver under app/Services/Artist/ is the pure-logic helper that
PerformanceResource calls. Greedy lowest-non-conflicting lane
assignment over the (stage_id, event_id) cohort sorted by start_at
then by raw lane (so cascade-bumped rows stay where they were
visually). Frontend port lands in Session 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:53:43 +02:00
bb1bd8361a feat(timetable): 13 form requests for artist domain endpoints
Created under app/Http/Requests/Api/V1/Artist/, mirroring the
existing FormRequest pattern (final class, authorize() returns true,
controller-level Gate::authorize). One request per CRUD shape plus the
two domain-specific endpoints:

  artists                     create / update
  genres                      create / update (with org-scoped unique)
  stages                      create / update (with event-scoped unique)
  stages/order                ReorderStagesRequest — permutation check
  engagements                 create / update — per RFC §10.3, with
                              ContractRequiresFee + OptionExpiresInFuture
                              conditional rules wired
  performances                create / update — per §10.2; cross-FK
                              engagement.event_id ↔ event_id chain
                              enforced via withValidator closure;
                              update is non-placement only (placement
                              edits go through /timetable/move)
  timetable/move              per §10.4; resolves target_event_id from
                              target_stage_id + target_start_at via
                              stage_days, then reuses StageActiveOnEvent
                              + WithinEventBounds for downstream rules
  stages/{stage}/days         §10.5 matrix replace; each event_id must
                              equal stage.event_id (flat) or be sub-event
                              (festival)

Custom error messages in Dutch where user-facing. Cross-FK rules that
span request inputs (engagement vs event-id chain, day matrix sub-event
membership) live in withValidator after-closures so the rule cache is
stable per request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:51:59 +02:00
378b6fe970 feat(timetable): four custom validation rules for artist domain
StageActiveOnEvent — checks the candidate stage_id is linked to the
given event_id via stage_days. Covers performance create/update
(perf.event_id ↔ stage) and the timetable move endpoint
(target_stage_id ↔ resolved target event).

WithinEventBounds — checks a candidate datetime is inside the event's
[start_at, end_at] window. Used for performance start/end dates and
move-target dates against the relevant sub-event for festivals.

OptionExpiresInFuture — conditional rule fired only when
booking_status === 'option'. Asserts option_expires_at is set and in
the future. Implementation of RFC §10.1 transition gate at the
request layer (the service layer enforces the same invariant).

ContractRequiresFee — conditional rule fired only when
booking_status === 'contracted'. Asserts fee_amount is set and > 0.
Same dual-layer enforcement as OptionExpiresInFuture.

All four pass silently when the validated field is null or the
context is irrelevant — the FormRequest still owns the surrounding
required/nullable/exists rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:50:12 +02:00
f7ed03237c feat(timetable): seven artist-domain services + supporting exceptions
GenreService, ArtistService, ArtistEngagementService (state machine),
StageService, StageDayService, PerformanceService, LaneCascadeService
under app/Services/Artist/. Plain final classes with constructor
injection — matches FormSubmissionService convention.

ArtistEngagementService implements the RFC §10.1 booking_status state
machine: terminal Cancelled/Rejected/Declined, Option requires future
option_expires_at, Contracted requires fee_amount > 0. transitionStatus
is the focused entry point; update() routes through it whenever the
payload mutates booking_status. cancel() composes transitionStatus +
soft delete in one transaction so the existing
ArtistEngagementObserver cascade fires.

LaneCascadeService is the D18 transactional move algorithm. Locks the
dragged Performance row FOR UPDATE, validates client version against
the persisted version (D14), then either parks (stage_id=null, no
cascade) or places onto (stage, event, lane) with single-level
cascade-bump of any time-overlapping rows on the target lane. Returns
a MoveResult value object carrying the moved + cascaded performances
so the controller maps them to API resources without a second query.

StageDayService implements the §10.5 atomic matrix replace. Detects
non-cancelled performances on event_ids about to be removed; throws
StageDaysOrphanedPerformancesException unless force_orphan=true. The
orphans are not deleted — they persist with the same stage_id so they
re-appear when the day re-activates (D5/D27 retention).

ArtistService.create raises DuplicateArtistException carrying the
existing master so the controller can offer a "use existing" choice
instead of forcing the booker to abandon their dialog. ArtistEngagement
defaults buma_handled_by based on artist.agent_company.handles_buma
per RFC D26.

GenreService.delete is hard-blocked (GenreInUseException) when artists
still reference the genre via default_genre_id; the frontend rebinds
those artists first.

StageService.delete cascade-parks performances (stage_id → null, lane
preserved) and returns the parked count for the activity-log entry
the controller writes in Step 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:49:18 +02:00
05e44a39ae feat(timetable): add 5 artist-domain policies
ArtistPolicy, ArtistEngagementPolicy, StagePolicy, PerformancePolicy,
GenrePolicy. Role-based authorization mirroring PersonPolicy/ShiftPolicy
pattern: super_admin bypass, org-membership check via wherePivotIn,
event_manager fallback for event-level operations.

Each policy carries a class-level docblock mapping the RFC §9
permission strings (events.view_program, events.manage_program,
organisations.manage_artists, organisations.manage_settings) to the
roles authorised, deferring permission-based authorisation to
AUTH-PERMISSIONS-MIGRATION.

ArtistPolicy.delete additionally guards on no-active-engagements
(D27): blocks soft-delete while any engagement is not Cancelled,
Rejected, or Declined.

PerformancePolicy.move and StagePolicy.reorder reuse canManageProgram
so the move endpoint and stage-reorder share the manage_program
permission semantics.

Auto-discovered by Laravel 11 (policies live at App\Policies\* matching
top-level App\Models\* — no explicit Gate::policy registration needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:45:46 +02:00
01f4a31fe1 feat(timetable): seed program_manager + production_assistant roles
Add the two RFC-TIMETABLE §9 roles. Authorization stays role-based per
Phase A Option B; RFC §9 permission strings map to roles in policy
class docblocks, not seeded as Spatie permissions. The eventual
cross-cutting migration to fine-grained permissions is tracked under
AUTH-PERMISSIONS-MIGRATION.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:44:05 +02:00
80ca599270 Merge pull request 'RFC-TIMETABLE v0.2 Session 1 — Artist Timetable foundation' (#15) from feat/timetable-session-1 into main
Reviewed-on: #15
2026-05-08 20:23:40 +02:00
7eec9d148f docs(backlog): record portal_token schema deviation from RFC v0.2 §5.3
Schema reality (varchar(64), accommodating SHA-256 hex digest) diverges
from RFC v0.2 §5.3 ("ULID unique nullable"). Session 1 implementation is
correct; RFC needs amendment in next legitimate cycle. Tracked under
RFC-TIMETABLE-V0.2-PORTAL-TOKEN-SCHEMA-AMEND. Distinct from
RFC-TIMETABLE-V0.2-DOC-CLEANUP (which covers stale cross-references).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:43:19 +02:00
a5190ee309 fix(timetable): null-on-delete advance_submissions per RFC §5.4 retention
advance_submissions.advance_section_id FK changed from cascadeOnDelete
to nullOnDelete; column made nullable. Aligns implementation with
RFC v0.2 §5.4 audit-immutability ("submissions remain for retention
compliance") — when ArtistEngagementObserver::deleted hard-deletes a
section, its submissions persist as orphans rather than disappearing.

Migration edited in place (branch unpushed, dev-only). Observer
docblock + test assertion updated to match. Removed pre-existing
follow-up comment that documented the deviation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:42:36 +02:00
e43dd60756 test(timetable): Phase C — artist domain coverage + cross-cutting fixes
New Phase C test files:
- tests/Unit/Models/Artist/ArtistDomainModelsTest.php — relationships,
  casts, soft-delete trait presence, slug uniqueness within/across
  organisations, isParked() helper, AdvanceSection's primary scope,
  PURPOSE_SUBJECT_FQCN['artist'] resolves to instantiable class.
- tests/Feature/Artist/ArtistEngagementObserverTest.php — auto-fill
  organisation_id from artist, cross-tenant guard throws, soft-delete
  cascades to performances + hard-deletes advance_sections.
- tests/Feature/Artist/PerformanceObserverTest.php — version starts
  at 0, increments by 1 per UPDATE, no bump on no-op save.
- tests/Feature/Artist/ArtistDomainScopeLeakageTest.php — 5 scoped
  models (Artist/Genre/Engagement direct + Stage/Performance FK-chain)
  isolate cross-org queries.
- tests/Feature/Artist/ArtistTimetableDevSeederTest.php — fixture-count
  smoke (4 stages, 12 stage_days, 6 artists, 12 engagements,
  13 performances incl. 1 parked).

Cross-cutting fixes that Phase C surfaced:
- AppServiceProvider: morph-map block 2 extended with the 8 new
  artist-domain models (artist_engagement, artist_contact, genre,
  stage, stage_day, performance, advance_section, advance_submission).
  Block 1 'artist' alias was already wired via PurposeRegistry.
- 5 form-builder backfill tests bumped --step rollback counts by +10
  to account for the 10 new May 8 migrations sitting at HEAD between
  the test's calibration point and current head.
- phpstan-baseline.neon regenerated (1631 entries) — all errors are
  same patterns existing baselined code already exhibits
  (Factory generic typing, Model property docblock gaps). Tracked
  systematically under TECH-LARASTAN-* in BACKLOG.

Tests: 1646 passing (was 1624 pre-Session-1 → +22 net, no losses).
Larastan: 0 errors over baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:15:38 +02:00