192 Commits

Author SHA1 Message Date
89d137e714 Add addtional test data using seeders for Artist Management module 2026-05-09 20:06:52 +02:00
3b255a36de feat(events): add Programma tab to EventTabsNav for timetable access
The timetable canvas page at /events/{event}/timetable was added in
RFC-TIMETABLE Session 4 but had no UI entry point. EventTabsNav now
exposes it as the "Programma" tab between Artiesten and Briefings on
flat events, and between Artiesten and Briefings on festivals (in the
re-ordered tab list, post-Artiesten / pre-Briefings).

Changes:
- baseTabs gains the Programma entry at position 6 (after Artiesten).
- The festival re-order computed switches from positional indexing
  (baseTabs[5], [6], [7]) to name-based lookup via a findTab helper —
  insertions to baseTabs no longer break the festival branch.
- Icon: tabler-calendar-time. Conservative Dutch label "Programma" —
  doesn't collide with "Programmaonderdelen" (the festival sub-events
  page) since festivals see both tabs side-by-side.

vitest.config.ts: extend the component-project AutoImport to include
'vue-router' so tests of components that auto-import useRoute/useRouter
mount cleanly. (EventTabsNav was the first such test.)

tests/component/EventTabsNav.test.ts (NEW, 4 assertions):
- Programma tab is rendered with the correct label
- it carries the tabler-calendar-time icon
- the route binding resolves to the events-id-timetable name with the
  /events/.../timetable URL pattern
- the tab is also visible on a festival (re-ordered tab list path)

Mocks the useEvents composables so the component skips its skeleton/
error branches and renders tabs immediately.

Test count: 385 → 389.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-09 08:58:22 +02:00
fb5ba5052e chore(timetable): drop unnecessary void on router.replace inside useActiveDay glue
Lint cleanup spotted during Phase C — `router.replace` returns Promise<void>
which the no-void rule rejects. The dropped void had no behavioural effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:58:37 +02:00
a156fe2a53 docs(backlog): add TEST-INFRA-001, TEST-CONTRACT-001, TEST-VISUAL-001 with sharp triggers; close ART-S4-TESTS
Three new entries that codify the test-architecture roadmap surfaced
during the Session 4 follow-up:

TEST-INFRA-001 — Migrate timetable component+a11y tests to Playwright
Component Testing. **Trigger: before opening the Sessie 5 prompt.**
Sessie 5 builds Engagement Detail (6 tabs) + Portal pages (drag-to-
reorder, file uploads); adding more jsdom-based tests for those
surfaces compounds the migration cost.

TEST-CONTRACT-001 — End-to-end 409 contract test against running Laravel.
Trigger: first e2e flow added after TEST-INFRA-001 lands. Highest
contract-protection value per line of test code.

TEST-VISUAL-001 — Visual regression baselines for PerformanceBlock
states (RFC D21/D22/D25/D26). Trigger: second addition to the
TEST-INFRA-001 sprint.

ART-S4-TESTS marked  Resolved with the audit trail of all 9 commits
that landed the test coverage closure (252 → 385 tests across both PRs).

.claude-sync/ regenerated by the post-commit hook (gitignored;
re-uploaded to Project Knowledge separately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:57:02 +02:00
985a5ab987 test(timetable): full add → drag → resize → park → delete integration flow (Step 12)
Two integration tests that drive the entire RFC D17 lifecycle through
the mutation composable + TanStack cache:

  1. happy-path lifecycle (5 stages):
     - ADD       → POST /performances + Idempotency-Key
     - DRAG      → POST /timetable/move (target_lane=1, version bumps),
                   server returns cascaded[] sibling — both surface in
                   the resolved Promise
     - RESIZE    → POST /timetable/move with new end_at + new version
     - PARK      → POST /timetable/move with target_stage_id=null
     - DELETE    → DELETE /performances/p1
     final wire: 4 POSTs + 1 DELETE
  2. drag rollback on 409:
     - server returns version_mismatch
     - mutation rejects with VersionMismatchError shape
     - notification.show() invoked with the Dutch toast + 'error'

Why not the full page mount: events/[id]/timetable/index.vue requires
EventTabsNav, useEventDetail, useEventChildren, multiple VTabs/VBtn/
VDialog teleports — too brittle for jsdom CI. The end-to-end + visual
flavour of this flow lives on TEST-INFRA-001's Playwright migration
backlog (and TEST-CONTRACT-001 covers the 409 path against a real
backend).

Test count: 383 → 385.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:55:22 +02:00
66a6f7ddc3 test(timetable): axe-core zero-violation a11y enforcement (Step 11)
Three jsdom axe scans covering the user-facing surface of the canvas.
The scans surfaced two real a11y bugs which are fixed in this same
commit:

  1. PerformancePopover — VProgressLinear (advancing aggregate) had no
     accessible name. Added aria-label that announces "X van Y secties
     afgerond (N%)".
  2. AddPerformanceDialog — the icon-only close button (×) was missing
     aria-label. Added 'Sluiten'.

Test scenarios:
  - PerformanceBlock with focus
  - PerformancePopover open
  - AddPerformanceDialog open

Page-level axe rules (region, page-has-heading-one, landmark-one-main,
color-contrast) are disabled for fragment scans — they only make sense
on a full page, and color-contrast resolution is jsdom-blind. Both are
covered by Playwright CT in TEST-INFRA-001 / TEST-VISUAL-001.

Test count: 380 → 383.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:53:16 +02:00
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
64878f2734 fix(timetable): wire portal-token auth through artist_engagements
RFC-TIMETABLE v0.2 §5.3 moved portal_token from artists to
artist_engagements (one master artist may have multiple per-event
portal links). PortalTokenController and PortalTokenMiddleware
queried the now-removed artists.portal_token column.

Update both lookups to query artist_engagements.portal_token, joining
to artists for the master name. Response shape unchanged: data.id =
engagement id, data.name = artist name, data.booking_status = engagement
status. Middleware sets portal_context='artist' (unchanged); the
attached portal_person object now carries the engagement row.

PortalTokenSecurityTest seeds artist_engagement rows via a private
helper that writes both an Artist (master) and an artist_engagements
row with the hashed token; test assertions adjusted to check the new
shape (no more milestone fields exposed since they don't exist on
the engagement).

Out of scope refactor disclaimer: this is a forced schema-migration
follow-up, not a Session 2-style controller refactor — the controller
queries the new table with minimal change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:15:13 +02:00
eb6d396672 fix(timetable): widen artist_engagements.portal_token to varchar(64)
PortalTokenController stores hash('sha256', \$plainToken) — a 64-char
hex digest. RFC v0.2 §5.3's "ULID unique nullable" annotation is loose;
in practice the column holds a hash, not a ULID. char(26) silently
truncates under MySQL strict mode (1406 Data too long) — surfaced
when PortalTokenSecurityTest exercised the auth path against the new
schema. Widen to varchar(64) to fit the hash.

Schema dump regenerated against crewli_test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:15:02 +02:00
4e5671daa9 docs(backlog): close ARCH-09; open ART-OBSERVER-ADVANCE-AGGREGATE + RFC-TIMETABLE-V0.2-DOC-CLEANUP
ARCH-09 (Artist Eloquent model + migration) closed under
"Opgeloste items (mei 2026)" with summary of what landed in
RFC-TIMETABLE v0.2 Session 1. Removed from Phase 3 status table
and from "Nieuwe backlog items".

Two new tech-debt entries:
- ART-OBSERVER-ADVANCE-AGGREGATE: AdvanceSection lifecycle observer
  to recompute artist_engagements.advancing_*_count, deferred to
  Session 3 when section-level submit lands.
- RFC-TIMETABLE-V0.2-DOC-CLEANUP: capture stale ARCH-PLANNED-MODULES.md
  cross-references in the Approved RFC v0.2 §1 + §15 for next amendment.
  Approved RFCs are not patched ad-hoc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:50:17 +02:00
ad6bf3b44d docs(form-builder): align artist_advance with engagement-scoped sections
§3.2.5: clarify that advance_sections are engagement-scoped (not
artist-scoped). One master artist with two engagements advances each
trajectory independently. Drop the prose section enumeration that
predated the AdvanceSectionType enum and conflated section names
with section types — section type is the enum, name is a free string,
default seeds land in Session 3 with ArtistAdvanceDefault.

§17.3: footnote on the artist_advance row documenting engagement
context resolution — ArtistResolver::fromPortalToken looks up
artist_engagements.portal_token, returns the master Artist as subject,
populates form_submissions.event_id from the engagement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:48:38 +02:00
7e4db29b2b docs(schema): rewrite §3.5.7 Artists & Advancing — RFC v0.2 alignment
Replaces the pre-RFC-v0.2 design (event-scoped artists, milestone bool
flags, artist_riders, itinerary_items) with the master+engagement
split per RFC-TIMETABLE v0.2 §5.3:

- genres (org-scoped vocab, D24)
- artists (master, org-scoped, slug-unique)
- companies.handles_buma column note
- artist_contacts (master-scoped)
- stages, stage_days (event/sub-event pivot)
- artist_engagements (per-event booking — D9, D10)
- performances (engagement-scoped, nullable stage_id, D13/D14)
- advance_sections (engagement-scoped — was artist_id)
- advance_submissions (audit-immutable per RFC §5.4)
- 7 enums under App\Enums\Artist\ documented in their own subsection

artist_riders and itinerary_items removed — RFC v0.2 §5.3 does not
create them; rider data lives in advance-section submissions, and
itineraries are deferred to a future RFC.

TOC anchor unchanged (slug `#357-artists--advancing` still resolves).
ARCH-PLANNED-MODULES.md was assumed to exist by the RFC's pre-amble
and the original session prompt, but does not — §3.5.7 was already in
SCHEMA.md, so the work is an in-place rewrite. Closes ARCH-09.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:47:27 +02:00
dd0d98f9ed refactor(timetable): PURPOSE_SUBJECT_FQCN — Artist::class instead of string-literal
The string-literal workaround was added before the Artist model existed
(ARCH-09 prerequisite). With the model now landed (RFC-TIMETABLE v0.2
Session 1), resolve to Artist::class directly so morph-map registration
matches the rest of the registry. MorphMapAlignmentTest still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:08:52 +02:00
3e3636dc53 feat(timetable): factories + ArtistTimetableDevSeeder
Eight factories with named states (Genre, Artist, ArtistContact, Stage,
ArtistEngagement, Performance, AdvanceSection, AdvanceSubmission).

ArtistTimetableDevSeeder hooked into DevSeeder::seedEchtFeesten after
the form-builder showcase. Produces:
- 4 stages (Mainstage, Havana, Stairway, Socialite) with prototype-style
  hex colours
- 4 stages × 3 sub-events = 12 stage_days rows
- 4 genres (Hardstyle, Techno, Indie, Live band)
- 6 master artists, each with one tour-manager ArtistContact
- 12 engagements with status mix (1 Draft, 2 Requested, 3 Option,
  2 Confirmed, 3 Contracted, 1 Cancelled). Two artists have two
  engagements each (different sub-events) — exercises D17 multi-
  engagement-per-artist.
- 13 performances, including one parked (stage_id=null = wachtrij)
  and one B2B pair within 3 minutes on Mainstage Saturday to seed
  the Session 4 frontend B2B detector.

Also fix LogOptions method name across 8 models: dontSubmitEmptyLogs()
→ dontLogEmptyChanges() (Spatie's actual API; surfaced when DevSeeder
ran).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:08:16 +02:00
85ad45c7e9 feat(timetable): observers — engagement denorm/guard + performance version bump
ArtistEngagementObserver:
- creating: auto-fills organisation_id from parent Artist (RFC v0.2 D10
  denormalisation), asserts artist.organisation_id == event.organisation_id;
  cross-tenant linkage throws CrossTenantEngagementException (extends
  DomainException, included in this commit).
- saving: no-op marker reserved for Session 2 state-machine validation.
- deleted: cascades soft-delete to Performance children, hard-deletes
  AdvanceSection children. AdvanceSubmission rows are immutable per
  RFC §5.4 and remain attached.

PerformanceObserver:
- saving: increments version by 1 on UPDATE only (D14 optimistic lock).
  MoveTimetablePerformanceRequest in Session 2 uses this for concurrent-
  edit detection.

Both observers registered in AppServiceProvider::boot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:01:42 +02:00
9ccf1eaceb feat(timetable): Artist domain — 7 enums + 9 Eloquent models
Enums under App\Enums\Artist\ (PascalCase per FormBuilder convention,
snake_case wire values per RFC):
- ArtistEngagementStatus (D9, 9 states + Dutch labels)
- BumaHandledBy (D26)
- FeeType, PaymentStatus
- AdvanceSectionType, AdvanceSectionSubmissionStatus, AdvanceSubmissionStatus

Models:
- Artist (org-scoped, slug-unique-per-org via creating boot hook)
- ArtistEngagement (per-event booking, denorm organisation_id)
- Genre, Stage (event-scoped, ordered scope), StageDay (Pivot, int PK)
- Performance (engagement-scoped, isParked() helper)
- AdvanceSection, AdvanceSubmission, ArtistContact (primary scope)

OrganisationScope wired:
- Direct organisation_id: Artist, Genre, ArtistEngagement
- FK-chain via tenantScopeStrategy(): Stage→Event, Performance→Engagement,
  AdvanceSection→Engagement, AdvanceSubmission→Section→Engagement,
  ArtistContact→Artist, StageDay→Stage→Event

Soft-deletes: Artist, ArtistEngagement, Performance (per RFC §5.4).
LogsActivity baseline (logFillable+dontSubmitEmptyLogs) on all business
models — actual mutation surfaces wire LogOptions in Session 2+.

Inverse relations added on Organisation, Event, Company.
companies.handles_buma cast added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:00:28 +02:00
0c03c449c3 feat(timetable): RFC v0.2 §5.3 migrations — artists, engagements, stages, performances, advancing
Ten migrations creating the artist + timetable foundation per
RFC-TIMETABLE v0.2 Session 1:

- genres (org-scoped vocab, D24)
- artists (master, org-scoped — slug-unique per org)
- companies.handles_buma column (D26 — BUMA flag on agencies)
- artist_contacts (master-scoped contacts)
- stages (event-scoped, sort_order per D23)
- stage_days (pure pivot stage↔event, integer PK)
- artist_engagements (per-event booking, denorm organisation_id, D9/D10)
- performances (engagement-scoped, nullable stage_id = wachtrij, D13/D14)
- advance_sections (engagement-scoped — was artist-scoped in pre-v0.2 plan)
- advance_submissions (audit-immutable per section)

Schema dump regenerated against crewli_test (migrate → schema:dump),
verified migrate:fresh round-trips cleanly with the dump as fast-path.

Closes part of ARCH-09.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:55:34 +02:00
c31f2ba784 chore(timetable): remove pre-RFC-v0.2 artist/advance_sections migration stubs
Anticipatory migrations from 2026-04-08 encoded the old §3.5.7 design
(artists.event_id, advance_sections.artist_id). RFC v0.2 §5.3 replaces
both tables with the engagement model. No model/factory/test/seeder
references exist. Removing before Step 1 ensures the new migrations
match RFC §5.3 verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:51:11 +02:00
3e54475d0b Merge pull request 'docs/rfc-timetable-v0.2-foundation' (#14) from docs/rfc-timetable-v0.2-foundation into main
Reviewed-on: #14
2026-05-08 17:28:47 +02:00
296e352e2d docs(rfc-timetable): mark v0.2 as Approved 2026-05-08 17:25:31 +02:00
c9863ee4f8 Add design en information for developing the Artist Management module 2026-05-08 17:01:13 +02:00
a57437a4b7 audit(timetable): complete prototype audit for RFC v0.2
Capture inventory, data model, component architecture, interaction
patterns, pure logic algorithms (with verbatim excerpts), design tokens,
and 20 RFC v0.2 observations from the standalone React prototype at
resources/Crewli - Artist  Timetable Management/.

Read-only audit; no prototype files modified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:04:00 +02:00
e8bd768212 Merge pull request 'TECH-CHANNEL-AUTH-ORG-ADMIN — Extend submission.{id} channel auth to organisation admins' (#13) from feat/channel-auth-org-admin into main
Reviewed-on: #13
2026-05-08 12:24:19 +02:00
5d53ccabae docs(backlog): close TECH-CHANNEL-AUTH-ORG-ADMIN
Mark TECH-CHANNEL-AUTH-ORG-ADMIN as resolved with PR reference,
date, and one-paragraph summary of what was delivered.

Three edits:

1. Open entry block removed from "Technische schuld" section.
2. Closure bullet appended under "Opgeloste items (mei 2026)" — full
   summary of the three-path auth (submitter / super_admin / org_admin),
   pattern source (FormSubmissionActionFailurePolicy::canAccess port),
   the audit-surfaced super_admin bypass bonus, test deltas, and
   sibling FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION pointer.
3. Stale forward-reference inside FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION
   updated: "submitter-only voor nu" → "submitter / super_admin /
   org_admin van submission's organisatie — TECH-CHANNEL-AUTH-ORG-ADMIN
   closed mei 2026". Closes the same no-compromises gap as the FORM-05
   stub-status touch-up (PR #12).

Sibling BACKLOG entry FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION stays
open — that's the frontend portal IdentityMatchBanner work that pairs
with this channel auth extension.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:31:08 +02:00
e04b084be5 test(broadcasting): add org-admin auth + cross-tenant guard tests
Per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN.

Four new tests + one deleted; existing three preserved.

NEW:
- test_super_admin_can_subscribe (positive, app-wide bypass via Spatie
  HasRoles assignRole('super_admin'))
- test_organisation_admin_of_submission_org_can_subscribe (positive,
  pivot-table org_admin → submission's organisation)
- test_organisation_admin_of_different_org_cannot_subscribe (CRITICAL
  cross-tenant guard — admin of org B cannot subscribe to a submission
  in org A)
- test_regular_organisation_member_cannot_subscribe (org_member role
  on the pivot is NOT enough; only org_admin passes)

DELETED:
- test_org_admin_is_currently_denied_per_backlog_entry (the "should
  flip" denied-by-default test from PR #11; superseded by the four
  positive/negative tests above)

PRESERVED:
- test_submitter_is_authorised
- test_other_authenticated_user_is_denied (User with no organisation
  membership → falls through every auth branch)
- test_subscription_is_denied_when_submission_does_not_exist

Test-fixture refinement: makeSubmission() now accepts an explicit
$submitter so positive role-based tests can use a separate User as
submitter, ensuring the submitter short-circuit doesn't accidentally
authorise role-based test subjects.

Test results: 7 passed in this file; 1624 in full suite (was 1621).
0 Larastan errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:29:01 +02:00
f5cb371023 feat(broadcasting): extend submission.{id} channel auth to organisation admins
Per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN.

WS-6 v1.3-delta D2 (PR #11 23a5696) introduced submission.{id} private
channel with submitter-only authorization, deferring org-admin auth
to a follow-up after the Spatie Permission helper convention was
audited. This commit closes that follow-up.

Authorization now permits (cheap-first short-circuit):
1. Submitter (submitted_by_user_id === user.id) — unchanged
2. super_admin (Spatie HasRoles app-wide bypass) — audit-surfaced bonus,
   matches every analogous policy in the codebase
3. Organisation admins of the submission's organisation — new

Pattern: direct port of FormSubmissionActionFailurePolicy::canAccess.
Spatie teams is disabled in config/permission.php, so org-scoping
lives in the user_organisation pivot table's `role` column with
wherePivot('role', 'org_admin') — codebase canonical (used in 17+
policy sites). withoutGlobalScopes() preserved on both FormSubmission
and Organisation lookups so channel auth is a structural gate, not a
tenant-scoped query.

Inline TODO removed; the BACKLOG entry transitions to resolved in a
follow-up commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:26:14 +02:00
39de4d5753 Merge pull request 'WS-6 v1.3-delta — Closure docs-PR' (#12) from docs/ws-6-v1.3-delta-closure into main
Reviewed-on: #12
2026-05-08 10:30:18 +02:00
c5682f181f docs(backlog): close no-compromises gaps from WS-6 v1.3-delta review
Three edits closing concessies surfaced in chat review of the closure
docs-PR:

1. FORM-05 'Resterend werk' sub-paragraph: surgical replacement of
   resolveStatus references (method removed in D2, PR #11 23a5696).
   Updated to describe post-D2 reality: gate + invariant +
   handle()-internal status derivation. Ticket stays open (the
   detectMatchesByValues extension is unbuilt).

2. FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION (NEW): tracks the frontend
   follow-up where the portal IdentityMatchBanner subscribes to the
   submission.{id} channel for live banner updates. Previously
   documented in PR #11 body and RFC §Q1 v1.3 add 2 commentary but
   without an actionable BACKLOG ticket.

3. HARD-DEADLINE-QUERY-TIMEOUT (NEW): tracks the upgrade from soft
   post-call microtime deadline to a hard deadline that can interrupt
   hanging MySQL queries (connection-level timeouts, MAX_EXECUTION_TIME
   hints, or pcntl_alarm). Previously documented as 'soft deadline
   limitation' inline in code comments without an actionable BACKLOG
   ticket.

No spec changes; no code changes. Closes the chat-identified gaps so
WS-6 v1.3-delta closure has zero un-anchored mental TODOs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:11:50 +02:00
ce552ec7be docs(backlog): WS-6 v1.3-delta closure entry + FORM-05 stub-status touch-up
Append WS-6-V1.3-DELTA closure bullet under "Opgeloste items (mei 2026)"
summarising D1 (PR #10 c6f4d1b) + D2 (PR #11 23a5696) deliverables and
open follow-ups.

Surgical correction to FORM-05 Stub-status paragraph: pre-D2 description
claimed TriggerPersonIdentityMatchOnFormSubmit writes initial 'pending';
post-D2 that's ApplyBindingsOnFormSubmit's job per RFC §Q1 v1.3 add 1.
The underlying ticket (detectMatchesByValues extension) stays open.

No other BACKLOG entries resolved — D1+D2 implemented RFC §Q3 v1.3
changes that pre-existing tickets didn't anticipate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:59:40 +02:00
5ac6b4168d docs(rfc-ws-6): mark v1.3.1 as fully implemented
§1 Status: add Implementation status line citing D1 (PR #10 c6f4d1b)
and D2 (PR #11 23a5696), both 2026-05-08.

§10 Document history: append v1.3-delta closure entry summarising what
D1 and D2 each delivered + what remains as separate operational task
(GlitchTip alert rule configuration in the web UI) and frontend
follow-up (Echo subscription).

No spec changes — purely lifecycle marker update.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:57:50 +02:00
7ba01a6dfa docs(runbooks): add form-builder binding failures section
Per RFC-WS-6 §Q3 v1.3 + ARCH-BINDINGS §11.

Nieuwe runbook-sectie §7 (na §6 Audit trail) die de triage-flow
documenteert wanneer GlitchTip een FormBindingApplicatorException
event opbrengt:

- §7.1 failure_response_code classificatie (schema_config_error /
  temporary_error / data_integrity_error / unknown_error) drijft het
  initiële triage-pad
- §7.2 form_schema.has_public_token tag onderscheidt klant-zichtbare
  failures (alert-waardig) van organizer-driven failures (admin-UI only)
- §7.3 retry/dismiss decision-matrix met form-failures:retry artisan
  command + DismissalReasonType enum cases
- §7.4 severe-failure escalatie criteria (>10/uur op één schema = P1)
- §7.5 cross-references naar RFC, ARCH-BINDINGS, en erasure-runbook

Companion van de operationele GlitchTip alert-rule (apart geconfigureerd
in de GlitchTip web UI op monitoring.hausdesign.nl).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:57:02 +02:00
23a5696288 Merge pull request 'WS-6 v1.3-delta D2 — Listener refactor + integration' (#11) from feat/ws-6-v1.3-delta-d2 into main
Reviewed-on: #11
2026-05-08 08:25:50 +02:00
1afe11609a test(form-builder): WS-6 v1.3-delta D2 tests
~30 new tests + 6 modified covering D2 deliverables.

NEW test files:
- FormSubmissionSubmittedListenerOrderTest: rewritten — flips
  identity-match assertion from sync to ShouldQueue + adds AST-level
  structural guard that every queued listener has the
  apply_status=COMPLETED gate as an early statement
  (form-builder.queued-listener.skipped_apply_failed log line + ApplyStatus::COMPLETED check).
- TriggerPersonIdentityMatchOnFormSubmitTest: rewritten — drops
  failsafe-pad assertions; adds gate-skip tests (null/PENDING/PARTIAL/FAILED);
  invariant-violation throw test; broadcast-dispatch test.
- ApplyBindingsOnFormSubmitTest: extended — initial
  identity_match_status='pending' write, apply_completed_at on both
  paths, classifier-derived failure_response_code per exception subclass,
  unknown_error fallback, deadline wrapper invocation captured by
  test double, outer-transaction failure record.
- SyncTagPickerSelectionsOnSubmitGateTest (NEW): canonical skip-log
  assertion for null/PENDING/PARTIAL/FAILED apply_status; no-skip-log
  assertion for COMPLETED. Uses Log::spy because FormTagSyncService
  is final and can't be Mockery-mocked.
- FormBindingApplicatorDeadlineTest (NEW): withDeadline returns clone;
  no-deadline path; generous-deadline path; timeout exception thrown
  with correct submissionId + reasonCode (temporary_error inherited
  via FormBindingInfraException). Uses incident_report purpose for
  anonymous-allowed branch to avoid PersonProvisioner constraints.
- RetryServiceFailureClassifierTest (NEW): per-subclass
  failure_response_code mapping in recordFailure; apply_completed_at
  symmetry-fix coverage.
- SubmissionChannelAuthTest (NEW): submitter authorised, other user
  denied, missing submission denied, org admin currently denied
  (locks v1 contract per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN).
- FormSubmissionResourceIdentityMatchTest: extended — DataProvider
  iterates over all six non-person purposes asserting
  identity_match=null per RFC §Q2 v1.3 contract.

MODIFIED to fit v1.3 layout:
- IdentityMatchOnSubmitTest: rewritten — directly invokes the listener
  with apply_status=COMPLETED pre-set, mirroring ApplyBindings'
  happy-path output (the test fixtures lack an identity-key binding
  so going through full event dispatch fails at PersonProvisioner).
  Drops the failsafe-pad assertion in test_public_submission_marked_pending;
  replaces with v1.3 contract: subject_type=null leaves
  identity_match_status untouched.
- TagPickerSyncListenerTest: same fix — sets apply_status=COMPLETED
  on the submission and invokes the listener directly.

Full suite: 1621 passing (4281 assertions). Larastan: 0 errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 03:20:27 +02:00
94205164ed docs(backlog): TECH-CHANNEL-AUTH-ORG-ADMIN — extend submission.{id} channel auth to org admins
WS-6 v1.3-delta D2 ships the broadcast channel auth callback in
routes/channels.php with submitter-only scope. Org-admin access is
deferred because the codebase has no vetted Spatie Permission helper
for organisation-scoped role checks; guessing the API would risk
incorrect authorisation without test coverage.

Tracking entry under "Technische schuld", referenced from the inline
TODO in routes/channels.php and the v1.3-delta D2 PR description.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 03:00:40 +02:00
03ff1cdfce feat(form-builder): apply_deadline_seconds config key (default 5)
Per RFC-WS-6 §Q1 v1.3 addition 4.

Configurable deadline for FormBindingApplicator::apply(). Default 5
seconds catches the long tail of slow applies before they hang the
public flow. Tunable per environment via FORM_BUILDER_APPLY_DEADLINE_SECONDS.

Consumed by ApplyBindingsOnFormSubmit::handle's withDeadline() call
(landed in Phase B).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:59:55 +02:00
012044f0bf fix(form-builder): FormFailureRetryService writes failure_response_code + apply_completed_at on retry failure
Per ARCH-BINDINGS §7.1 v1.2 retry-service asymmetry note + RFC-WS-6 §Q3 v1.3 addition 2.

recordFailure() now mirrors ApplyBindingsOnFormSubmit's outer-transaction
failure path:

1. failure_response_code via FormBindingExceptionClassifier::classify($e).
   Same classification logic as the listener — single behaviour-change
   point per the v1.3-delta D1 design.
2. apply_completed_at = now() — closes the asymmetry where the listener
   wrote this column on both happy and failure paths but the retry
   service only wrote it on the success path.

recordSuccess() unchanged — already writes apply_completed_at via the
shared transaction block in retry().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:59:31 +02:00
fa06c0f9f3 feat(form-builder): add apply_status=COMPLETED gate to SyncTagPickerSelectionsOnSubmit
Per ARCH-BINDINGS §5.6 v1.2.

The queued tag-sync listener now skips unless apply_status === COMPLETED.
PARTIAL and FAILED both fall through to the early-return — rebuilding
user_organisation_tags against a Person whose tag-binding may have been
the binding that failed would propagate partial state into derived data.

Logs at info level when skipped (form-builder.queued-listener.skipped_apply_failed)
for triage visibility. The fresh() reload is required because the inner-txn
commit happens between dispatch and worker pickup.

ApplyBindingsOnFormSectionSubmitted (the other queued listener under
app/Listeners/FormBuilder/) listens to FormSubmissionSectionSubmitted, a
different event — the §5.6 gate is specifically about
FormSubmissionSubmitted's post-apply-status state, so the section-level
listener is intentionally left without this gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:58:09 +02:00
912022f5da feat(form-builder): broadcast channel auth + listener layout comment update
Per RFC-WS-6 §Q1 v1.3 addition 2.

- routes/channels.php (NEW): authorization callback for the
  submission.{id} private channel. v1 authz scope is submitter-only
  (matches submitted_by_user_id); org-admin access is deferred per
  BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN. Frontend Echo subscription
  lands as a separate frontend follow-up.
- bootstrap/app.php: registers routes/channels.php via withRouting()
  channels: parameter. This is NEW broadcasting wiring — Laravel's
  broadcasting auth middleware was not previously connected to the
  framework. Without this registration the channels file is dead code.
- AppServiceProvider:👢 comment block updated to v1.3 listener
  layout (1 sync ApplyBindings + N queued, all gated on
  apply_status=COMPLETED per ARCH-BINDINGS §5.6). Comment on
  TriggerPersonIdentityMatch flipped from "(sync)" to "(queued
  post-v1.3)".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:57:22 +02:00
2a8f108b0e feat(form-builder): TriggerPersonIdentityMatch becomes queued + invariant throw
Per RFC-WS-6 §Q1 v1.3 (queueing) + §Q2 (invariant + IdentityMatchInvariantViolation)
+ §Q1 v1.3 addition 2 (broadcast).

- Implements ShouldQueue (was sync). Gate as first statement: skip if
  apply_status !== COMPLETED (handles PARTIAL and FAILED identically per
  ARCH-BINDINGS §5.6). Logs at info level when skipped for triage
  visibility.
- Failsafe-pad removed in favour of strict invariant: subject_type='person'
  + apply_status=COMPLETED implies subject_id IS NOT NULL. Violation throws
  IdentityMatchInvariantViolation, routed via Laravel queue worker to
  GlitchTip + form_submission_action_failures.
- Status derivation preserved (string semantics 'matched'/'pending'/'none')
  — PersonIdentityService::detectMatches returns a Collection; status
  computed via user_id check + isNotEmpty(). matchCount derived from
  $matches->count() for the broadcast payload only (not persisted).
- Person-not-found between dispatch and worker pickup terminates as
  'none' rather than throwing — rare race-window where the person was
  deleted; banner gets a sensible final state.
- Dispatches FormSubmissionIdentityMatchResolved on the submission.{id}
  private channel after writing the final identity_match_status.

Frontend Echo subscription is a separate follow-up (out of WS-6 scope).
The 4 existing failsafe-pad tests need rewriting in Phase I.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:56:10 +02:00
762fc62efa feat(form-builder): wire D1 building blocks into ApplyBindings + add deadline wrapper
Per RFC-WS-6 §Q1 v1.3 addition 1, 4 + §Q3 v1.3 addition 2 + ARCH-BINDINGS §5.3.

- FormBindingApplicator::withDeadline(int) returns a clone configured to
  throw FormBindingApplicatorTimeoutException if apply() exceeds the
  deadline. Soft post-call microtime check; cannot interrupt mid-query
  but catches the long tail. apply() refactored to single-return so the
  deadline check sits at one site instead of duplicated.
- ApplyBindingsOnFormSubmit::handle:
  - Initial identity_match_status='pending' write inside inner
    transaction (when subject is or becomes a person) so HTTP response
    carries the right state for the IdentityMatchBanner first-paint
    copy. Final state comes from the queued TriggerPersonIdentityMatch
    (D2 Phase C).
  - Wraps apply() with config('form_builder.apply_deadline_seconds', 5).
  - Catch block uses FormBindingExceptionClassifier::classify to write
    failure_response_code in the outer transaction alongside
    apply_status=FAILED. submission_id from the exception (when in the
    binding-applicator hierarchy) is also captured in context JSON.

Tests added in Phase I.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:55:11 +02:00
c6f4d1b5c6 Merge pull request 'WS-6 v1.3-delta D1 — Foundation delta (data layer + exception hierarchy)' (#10) from feat/ws-6-v1.3-delta-d1 into main
Reviewed-on: #10
2026-05-08 02:32:34 +02:00
c29ad75ecc test(form-builder): WS-6 v1.3-delta D1 tests
32 new tests covering D1 deliverables:

- Migration shape (3): failure_response_code column presence,
  type/length/nullability, index name. MySQL information_schema
  introspection.
- Exception hierarchy (11): abstract base, RuntimeException ancestor,
  per-subclass constructor + reasonCode (named-args asserting
  submissionId is preserved structurally), Timeout extends Infra and
  inherits temporary_error, all subclasses extend base, previous-throwable
  chaining works, IdentityMatchInvariantViolation is NOT in the
  binding-applicator hierarchy and IS a DomainException.
- FormBindingExceptionClassifier matrix (6): each subclass maps to its
  reason code; Timeout dispatches to inherited 'temporary_error';
  arbitrary RuntimeException -> 'unknown_error'; IdentityMatchInvariantViolation
  -> 'unknown_error' (intentional fallback per docstring).
- FormFieldBindingMergeStrategy::validForTargetType (4 tests covering
  the full 4 strategies x 3 target types matrix).
- FormSubmissionIdentityMatchResolved (4): ShouldBroadcast contract,
  private channel naming ('private-submission.{id}'), broadcast-as
  string, payload assignment.
- FormSubmission failure_response_code cast (4): persists as plain
  string, NULL by default, factory state composes with apply_status,
  round-trips for all four canonical codes.

Baseline regenerated to absorb new tautological-assertion entries (48
lines) — these are class-hierarchy regression guards that Larastan
correctly flags as statically known. The pattern is established in the
codebase per existing baseline entries for similar tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:09:48 +02:00
01c5ff207a test(form-builder): bump remaining backfill-test step counts for WS-6 v1.3-delta D1 migration
Same root cause as 832375b — the new failure_response_code migration
sits at the top of the WS-5/WS-6 stack, so every test that pins --step
to walk back through that stack needs +1.

- FormFieldOptionsBackfillTest:     6 -> 7  (10 occurrences)
- ConditionalLogicBackfillTest:    10 -> 11 (4 occurrences)
- FormFieldConfigBackfillAndDropTest: 16 -> 17 (1 occurrence)
- FormFieldValidationRuleBackfillTest: 19 -> 20 (7 occurrences)

Total: 22 backfill tests now green again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:07:43 +02:00
96062b9182 feat(form-builder): FormSubmission cast + factory state for failure_response_code
Per RFC-WS-6 §Q3 v1.3 addition 2.

- Added 'failure_response_code' to FormSubmission $fillable + 'string' cast.
  Plain string (not enum) — the exception subclass on
  form_submission_action_failures is the canonical classification source;
  this column is a denormalised mirror for response-shape rendering.
- Factory fluent state method withFailureResponseCode() with documentation
  of the four valid values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 02:00:18 +02:00
1f66fef3c8 feat(form-builder): FormBindingExceptionClassifier helper
Per RFC-WS-6 §Q3 v1.3 addition 2.

Centralises the Throwable -> failure_response_code mapping so the
listener (ApplyBindingsOnFormSubmit::handle catch block) and the
retry-service (FormFailureRetryService::recordFailure) produce
identical classifications. Single behaviour-change point.

Resolution order: FormBindingApplicatorException subclass dispatch via
reasonCode(); fallback 'unknown_error' for anything outside the hierarchy.

Wiring into the listener and the retry service lands in D2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:59:32 +02:00
b7bd7904c2 feat(form-builder): FormSubmissionIdentityMatchResolved broadcast event
Per RFC-WS-6 §Q1 v1.3 addition 2.

Broadcast event class only — not yet dispatched. D2 wires the dispatch
call into TriggerPersonIdentityMatchOnFormSubmit::handle (after the
final identity_match_status write), and the channel-authorization
callback into routes/channels.php.

Frontend Echo subscription is a separate frontend follow-up (out of
WS-6 v1.3-delta scope).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:59:10 +02:00
b6b63a7121 feat(form-builder): validForTargetType method on FormFieldBindingMergeStrategy
Per RFC-WS-6 §V1 + ARCH-BINDINGS §4.2.

Implements the strategy x target-type validity matrix. Append is the
only non-trivial case: valid only for COLLECTION targets. The
AppendStrategyRequiresCollectionTarget publish-guard uses this method
(D2 wiring confirms call sites; this commit provides the building block).

Existing methods (nullWinnerBehaviour, isValidForScalarTargets) untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:58:47 +02:00
f94b3fb329 feat(form-builder): exception hierarchy for binding-apply pipeline
Per RFC-WS-6 §Q3 v1.3 addition 2 (binding hierarchy) + §Q2 (invariant exception).

- Refactored FormBindingApplicatorException from concrete final to abstract
  base. Constructor (submissionId, message, previous?) preserves submissionId
  as a public readonly property so D2's outer-transaction handler can write
  it structurally to form_submission_action_failures.context JSON without
  regex-parsing the message. Replaced public-readonly reasonCode property
  with abstract reasonCode(): string method.
- Added 3 reason-coded subclasses:
  - FormBindingSchemaConfigException -> 'schema_config_error' (422)
  - FormBindingInfraException -> 'temporary_error' (503, NOT final because
    Timeout extends it)
  - FormBindingDataIntegrityException -> 'data_integrity_error' (422)
- Added FormBindingApplicatorTimeoutException extending FormBindingInfraException
  (timeout = temporary infra issue from user perspective; reasonCode inherited).
- Added IdentityMatchInvariantViolation as a sibling DomainException — NOT
  in the FormBindingApplicatorException hierarchy because it's thrown
  outside the binding-applicator pipeline.
- Migrated 3 existing throw sites in FormBindingApplicator::apply():
  - 'no_transaction' -> FormBindingInfraException (developer-error wants
    infra-triage workflow: GlitchTip alert + retry-after)
  - 'no_schema' -> FormBindingSchemaConfigException
  - 'unknown_purpose' -> FormBindingSchemaConfigException
- Updated FormBindingApplicatorIntegrationTest::test_no_transaction_guard_present
  to assert against the new throw shape (FormBindingInfraException + new
  message string) while preserving the test's intent (guard exists in source).

Wiring (deadline wrapper, classifier integration in listener catch +
retry-service recordFailure) lands in D2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:58:11 +02:00
832375b086 test(form-builder): bump migration step counts for WS-6 v1.3-delta D1 migration
The forward + rollback migration tests pin --step to a fixed count to
walk the WS-5/WS-6 stack back to known pre-states. The new
2026_05_08_000001_add_failure_response_code_to_form_submissions
migration sits at the top of that stack, so both rollback step counts
need +1 to reach the same destinations.

- pre-WS-5a rollback: --step 21 -> 22 (used twice)
- pre-WS-5b rollback (from fully-forward): --step 19 -> 20 (used once)

Comments updated to enumerate the v1.3-delta D1 migration in the WS-6
group.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:57:44 +02:00
e32de8a0f0 feat(form-builder): add failure_response_code column to form_submissions
Per RFC-WS-6 §Q3 v1.3 addition 2 + ARCH-BINDINGS §7.1 v1.2.

Denormalised mirror of the FormBindingApplicatorException subclass
classification, written by ApplyBindingsOnFormSubmit's outer-transaction
catch block (D2) when apply_status='failed'. Drives response-shape copy.
NULL when apply_status is not 'failed'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:53:13 +02:00
b2558791e6 docs(rfc-ws-6): v1.3.1 + ARCH-BINDINGS v1.2 — drift closure pre-D1 implementation
Three code-vs-docs drifts surfaced by the 2026-05-08 v1.3-delta audit.
None changes architecture; all three close the gap between code on main
(845b6e6) and the v1.3 amendment text.

- RFC §3 (Q1): apply_status enumerations updated to four cases (added
  PARTIAL alongside PENDING/COMPLETED/FAILED). PARTIAL is the
  BindingPassResult outcome when the pass committed with mixed
  per-binding outcomes; not a separate runtime path. Long-term direction
  remains BACKLOG PARTIAL-BINDING-SUCCESS.
- ARCH-BINDINGS §5.6: new "PARTIAL handling" subsection clarifying the
  gate treats PARTIAL identically to FAILED until partial-success work
  lands. The gate code itself was already correct (strict equality on
  COMPLETED); this closes the explanatory gap.
- ARCH-BINDINGS §7.1: status-columns table extended with apply_completed_at
  row. Intro line updated. Retry-service asymmetry noted as D2 follow-up
  (FormFailureRetryService::recordFailure currently does not write
  apply_completed_at; D2 fixes this).

RFC v1.3 -> v1.3.1; ARCH-BINDINGS v1.1 -> v1.2.

Refs: dev-docs/RFC-WS-6.md, dev-docs/ARCH-BINDINGS.md, dev-docs/BACKLOG.md (PARTIAL-BINDING-SUCCESS, unchanged)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:32:19 +02:00
845b6e6a0e docs(rfc-ws-6): v1.3 amendment — listener queueing, invariant cleanup, failure-UX
Five refinements from the 2026-05-07 architectural review:

- Q1: TriggerPersonIdentityMatchOnFormSubmit moves to queued; sync-chain reduced to ApplyBindings only; queued-listener gating invariant; sync-chain deadline wrapper.

- Q2: Failsafe pad in TriggerPersonIdentityMatch removed in favour of strict invariant + throw; RequiresIdentityKeyBinding unconditional for event_registration; FormSubmissionResource.identity_match=null contract for non-person purposes.

- Q3: Three failure-UX additions (GlitchTip alert, custom exception hierarchy + error_code, BACKLOG entries for partial-success and schema-drift).

Spine unchanged: pre-publish guards, strict service / log-and-swallow listener, two-transaction pattern, single identity-key per target_entity.

Refs: dev-docs/RFC-WS-6.md (now v1.3), dev-docs/ARCH-BINDINGS.md (now v1.1), dev-docs/BACKLOG.md (PARTIAL-BINDING-SUCCESS, FORM-SCHEMA-DRIFT-DETECTION added)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:52:19 +02:00
1af7b9506d Add RFC-WS-6.md to the documentation sync 2026-05-07 23:00:35 +02:00
c398772a23 Merge pull request 'WS-7 Observability — closure' (#8) from feat/ws-7-observability into main
Reviewed-on: #8
2026-05-07 22:49:24 +02:00
d4a450d193 docs(backlog): mark WS-7 Observability as closed (mei 2026)
Acceptance criteria 1-14 voldaan; observability volledig operationeel
op monitoring.hausdesign.nl. Implementation criteria 3, 4, 5, 6, 8,
11, 12, 13, 14 via 4 PRs op feat/ws-7-observability; operationele
criteria 1, 2, 7, 9, 10 via deploy-checklist.

Hernoem 'Observability follow-ups (post WS-7)' sectie-header naar
'(post WS-7 closure)' voor accuratesse na PR-3 + PR-4. Closure-entry
geplaatst onderaan 'Opgeloste items (mei 2026)' om chronologische
volgorde (oldest-first) te respecteren — WS-7 op 2026-05-07 volgt
WS-3 PR-C op 2026-05-06 die volgt op WS-TOOLING-001 op 2026-05-05.

Refs: dev-docs/ARCH-OBSERVABILITY.md, dev-docs/runbooks/observability-{triage,erasure}.md
2026-05-07 22:37:15 +02:00
e9da01ffce docs: WS-7 closure — RFC status + SECURITY_AUDIT + BACKLOG + sync config
PR-4 commit 3 — closure-bookkeeping nu de implementation-PRs en de
twee runbooks gemerged zijn.

- RFC-WS-7-OBSERVABILITY.md: nieuwe §9 Implementation status (mei 2026)
  vat samen welke acceptance criteria via PR-1..PR-4 zijn voldaan en
  welke (1, 2, 7, 9, 10) op Bert's deploy-checklist resteren. Pointer
  naar ARCH-OBSERVABILITY.md als levende reference; de RFC blijft
  historisch document.
- SECURITY_AUDIT.md: nieuwe sectie 'WS-7 Observability — finale audit
  (mei 2026)' tussen A13-10 en Positive Findings. Bevat (1) acceptance
  criteria checklist met status per criterium, (2) processing register
  entry voor GlitchTip (controller-not-processor, retention 90 dagen,
  TLS+full-disk-encryption+2FA), (3) zeven security controls die WS-7
  introduceert (PII scrubbing, CSP whitelist, sourcemap upload-only,
  listener registration discipline, runtime portal-context-split,
  multi-tenant tag invariant, impersonation.active binary signal),
  (4) pointer naar runbooks/observability-erasure.md voor Art. 17.
- BACKLOG.md: status-overzicht-tabel boven de OBS-entries. Toegevoegd
  als entry: OBS-2 (early-pipeline log context,  Resolved), OBS-3
  (sentry-context middleware coverage,  Resolved — opgevouwen in
  AuthScopeContextListener), OBS-5 (Crewli render handlers report()
  invariant,  Resolved via 48f2a00 + ExceptionReportingTest), en
  OBS-9 (Active — staging environment GlitchTip CSP whitelist follow-up
  bij staging-introductie). Bestaande OBS-1, 4, 6, 7 ongewijzigd
  (Active); OBS-8 staat al op Resolved sinds dee1401.
- .claude-sync.conf: drie nieuwe doc-paths toegevoegd
  (ARCH-OBSERVABILITY.md, runbooks/observability-triage.md,
  runbooks/observability-erasure.md). Post-commit sync-claude-docs
  hook regenereert SYNC_MANIFEST.md met deze entries.

Closes WS-7 documentation acceptance criteria 8 (ARCH) en 14
(SECURITY_AUDIT). Resterende criteria (1, 2, 7, 9, 10) zijn
deploy-checklist door Bert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:47:12 +02:00
bf89090850 docs: observability triage + erasure runbooks
PR-4 commit 2. Both runbooks live under dev-docs/runbooks/ as the first
entries in that directory.

- observability-triage.md (270 lines): incoming-issue procedure. Tags
  inspectie (actor_scope, release, actor_type, organisation_id,
  impersonation), triage classes (P0–P3), reproductie via request_id
  correlation naar laravel.log, common patterns (validation leakage,
  runaway errors, multi-tenant invariant violations, CSP black-silence),
  resolution + audit trail.
- observability-erasure.md (293 lines): GDPR Art. 17 procedure.
  Trigger voorwaarden (upstream eerst), pre-checks, handmatige
  psql-procedure met counts vóór delete, post-checks, automation
  BACKLOG verwijzing, edge cases (no-events-in-window,
  impersonation-target, queued events, mass-erasure batch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:46:49 +02:00
754222f74d docs: ARCH-OBSERVABILITY.md (WS-8b)
Replaces the WS-6 skeleton with a full post-implementation reference
for the observability stack. Eleven sections covering scope, component
overview, tag taxonomy (replacing RFC §3.6 as source-of-truth), tag
binding architecture, scrubbing semantics, runtime context split, CSP
whitelist, sourcemap upload, GDPR + privacy, maintenance + extension
guidance, plus cross-references.

Form Builder exception classification from the old skeleton §3 is
preserved in §5.4 — concrete answer for which Crewli exception
classes do or do not go to GlitchTip.

Lengte: 730 regels markdown. Closes WS-7 acceptance criterion 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:46:32 +02:00
5c42f27b26 fix: whitelist GlitchTip ingest host in CSP connect-src
PR-3 follow-up. Live smoke surfaced that the @sentry/vue SDK was
running correctly and emitting events, but Crewli's strict
connect-src directive blocked every POST at the browser layer. No
fallback — events evaporated silently with a CSP-violation log in
DevTools console only.

Updated locations (audited the CSP surface; only two locations actually
need the whitelist):

- apps/app/index.html — dev meta CSP, adds http://localhost:8200 to
  connect-src so local dev hits the docker-compose GlitchTip stack.
- deploy/nginx/csp-spa.conf — prod organizer SPA CSP, adds
  https://monitoring.hausdesign.nl to BOTH the report-only and enforce
  add_header lines so a future flip between modes can't silently break
  observability.

NOT updated (deviation from prompt):

- api/config/security.php — the API CSP is `default-src 'none';
  frame-ancestors 'none'` for JSON responses. Browsers don't enforce
  connect-src on JSON contexts (no document, no fetch origin). Adding
  connect-src would be semantically a no-op and confuse the deny-by-
  default policy.

Regression guard: tests/Feature/Security/CspConnectsToObservabilityTest.
Reads both the dev meta tag and the prod nginx conf directly (the SPA's
CSP is not Laravel-served, so $this->get() can't reach it). Apply-with-
revert verified: stashing both fixes makes both cases fail with a clear
"Refused to connect because it violates the following CSP directive"
hint; popping the stash restores green.

SECURITY_AUDIT.md A13-9 updated with a WS-7 follow-up note documenting
the GlitchTip whitelist as an explicit security control: outgoing
observability traffic restricted to a single known host.

Test count 1549 to 1551. Larastan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:36:05 +02:00
53ae1a686c docs: WS-7 PR-3 acceptance criteria progress
WS-7 PR-3 commit 4. RFC §6 acceptance criteria 4, 5, 6 now satisfied
by the frontend SDK PR; entries marked  with brief implementation
references.

Updated criterion 4 to reference Crewli's actual token-based portal
paths (/portal/advance/:token, /register/:public_token) instead of the
RFC's speculative /p/* — the contextBinding guard detects via
route.meta.public + route.meta.context which is the canonical Crewli
signal already used by other guards.

Added a "Voortgang (mei 2026)" subsection at the end of §6 mapping
each PR to the acceptance criteria it closed, plus what remains for
PR-4 (live smoke, ARCH-OBSERVABILITY.md, alerting config, retention
config, SECURITY_AUDIT.md update).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:00:54 +02:00
17373da1a5 feat: sourcemap upload to GlitchTip in deploy.sh
WS-7 PR-3 commit 3, RFC §3.5.

- deploy.sh: export VITE_SENTRY_RELEASE=crewli-app@<short-sha> before
  the Vite build so the release identifier is inlined into the bundle
  via import.meta.env.
- New step 4a after the build: when SENTRY_AUTH_TOKEN and
  VITE_SENTRY_DSN_FRONTEND are present, upload sourcemaps via
  `npx @sentry/cli@latest sourcemaps upload` to project crewli-app
  with --url-prefix=~/assets/ matching Vite's default asset path.
  Soft-fails with a warning so deploy can still succeed if GlitchTip
  is unreachable.
- Always run `find apps/app/dist -name '*.map' -delete` after upload
  (or after skipped upload). No public-mapped sources reach nginx —
  RFC §3.5 invariant.
- .gitignore: defensive `apps/app/dist/**/*.map` exclusion (dist/ is
  already broadly ignored; this is belt-and-suspenders against
  accidental commits of build output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:59:58 +02:00
9247d89e4b test: scrubber + contextBinding regression coverage
WS-7 PR-3 commit 2.

- scrubber.spec.ts (18 cases): mirrors backend PiiScrubbingTest semantics.
  Body/header/query scrubbing, form_values wholesale replacement, all
  SENSITIVE_BODY_KEYS at top + nested levels, max_depth guard, cookies +
  storage + user.cookies sanitisation.
- contextBinding.spec.ts (11 cases): exercises the Vue Router beforeEach
  guard against a real router with mocked Sentry scope (capturing every
  setTag/setUser call into a per-test buffer). Cases:
    - portal-token zone — actor_scope=portal, no user_id
    - platform route + super_admin — actor_scope=platform
    - platform route without super_admin — does NOT tag platform
    - organizer route with active org — actor_scope=organisation +
      organisation_id
    - organizer route without active org — actor_scope=user, no org tag
    - unauthenticated public — actor_scope=anonymous
    - actor_type role hierarchy
    - RFC §3.8 ULID-only user identity (no email leakage)
    - route_name + app=app baseline tags
    - cross-zone leak guard: navigating from organizer to portal-token
      calls scope.clear() and does not bind user

Frontend test count 223 to 252. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:59:05 +02:00
bc477837eb feat: install @sentry/vue + observability module skeleton
WS-7 PR-3 commit 1. Frontend mirror of the backend SDK install
(commits bdb89a2..adab3be), wired against the existing apps/app SPA.

- pnpm add @sentry/vue@10.52.0 (pinned).
- src/observability/sentry.ts: initSentry() — empty DSN no-op (RFC §3.3),
  errors-only (tracesSampleRate=0, profilesSampleRate=0; RFC §2 amend.B),
  sendDefaultPii=false, Console integration off, beforeSend wired to the
  scrubber, initial scope tag app=app for GlitchTip filtering.
- src/observability/scrubber.ts: TypeScript port of backend
  SentryEventScrubber. RFC §3.7 frontend block — body / header / query
  scrubbing, form_values wholesale replacement, cookies wholesale,
  defensive strip of contexts.storage and user.cookies, max-depth guard.
- src/observability/contextBinding.ts: Vue Router beforeEach guard that
  binds RFC §3.6 auth-scope tags per navigation. Three zones via
  route.meta.public + route.path matching:
    - portal token zone (meta.public + meta.context=portal) → actor_scope=
      portal, no user_id (RFC §3.6 explicit)
    - /platform/* with super_admin → actor_scope=platform, no org tag
    - default authenticated → actor_scope=organisation when an active
      organisation is selected (useOrganisationStore.activeOrganisationId),
      otherwise actor_scope=user
    - unauthenticated public pages → actor_scope=anonymous
  Reads useAuthStore (user, appRoles, isSuperAdmin) and
  useOrganisationStore (activeOrganisationId) — corrected vs. RFC's
  speculative auth-store API.
- src/observability/index.ts: barrel.
- src/main.ts: initSentry runs before registerPlugins so Sentry's Vue
  errorHandler hooks before any plugin or component initialises;
  installContextBinding runs after registerPlugins so pinia is up.
- env.d.ts: VITE_SENTRY_DSN_FRONTEND + VITE_SENTRY_RELEASE typed.
- .env.example: new file (didn't exist before) documenting all SPA env
  vars including the new Sentry pair.
- vite.config.ts: build.sourcemap=true (RFC §3.5 — generated, uploaded
  to GlitchTip by deploy.sh, then stripped before nginx serves dist/).

Typecheck: green. Build: green, *.map files emitted alongside *.js
chunks as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:56:21 +02:00
dee140193e test: regression guards for listener registration uniqueness + always-present binary tags
Drie regression-tests die de klasse fouten uit PR-2 nazorg empirisch
voorkomen:

1. test_authenticated_listener_registered_exactly_once
2. test_token_authenticated_listener_registered_exactly_once
3. test_job_processing_tag_listener_registered_exactly_once
   — vangen OBS-8 patroon (auto-discovery + explicit listen samen) plus
   accidentally-removed registrations door toekomstige refactors. Walk
   Event::getRawListeners() en faalt met count != 1 met een duidelijke
   message ("auto-discovery re-enabled? OR explicit Event::listen
   missing?"). Empirisch geverifieerd: zowel duplicate als missing
   registratie wordt gevangen.

4. test_impersonation_active_tag_invariant_on_captured_events
   — RFC §3.6 binary signal invariant op een echte HTTP request flow.
   Vangt regressie waar de baseline-tag-binding verdwijnt.

BACKLOG.md OBS-8 entry toegevoegd en gemarkeerd als Resolved met
verwijzing naar de drie commits van deze sessie + architecturaal
pattern (explicit > implicit voor observability-kritische bindings).

Test count 1545 to 1549. Larastan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:35:11 +02:00
a939820122 fix: impersonation.active default tag for non-impersonation authenticated events
RFC §3.6 vereist impersonation.active als always-present binary signal
op authenticated events. Originele PR-2 architectural-fixes verplaatste
impersonation-tagging naar HandleImpersonation middleware, die alleen
draait bij actieve impersonation. Resultaat: non-impersonation events
hadden GEEN tag, niet 'false' tag — wat filtering op "alle impersonation
events" in GlitchTip stilletjes onmogelijk maakte.

Fix: AuthScopeContextListener::bindForUser() zet baseline 'false';
HandleImpersonation overschrijft naar 'true' + impersonator_user_id
wanneer actief. Default-in-listener, override-in-middleware pattern.
HandleImpersonation deed de override-set al correct sinds commit
9414d09; alleen de baseline ontbrak.

Bert's live verification toonde de gap: super_admin event zonder
impersonation actief, GlitchTip event zonder impersonation.active tag.

Tests:
- test_impersonation_active_default_false_for_non_impersonation_authenticated_event
  (was test_authenticated_event_does_not_set_impersonation_tags;
  hernoemd + assertion gewijzigd)
- test_impersonation_active_default_false_across_every_actor_scope_branch
  walks elke actor_scope branch (user/organisation/platform) en bewijst
  baseline geldt uniform — vangt toekomstige refactors die per branch
  vroegtijdig returnen.

Test count 1544 to 1545. Larastan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:30:27 +02:00
215405ad50 fix: disable Laravel listener auto-discovery; explicit registrations only
Auto-discovery + explicit Event::listen() runt observability listeners
twee keer per event (verified via php artisan event:list duplicate
entries). Vandaag idempotent vanwege scope-tag overwrite semantics, maar
architecturaal onacceptabel — toekomstige additive listeners zouden
onmiddellijk breken zonder waarschuwing.

Optie A (Bert bevestigd, RFC-WS-7 OBS-8): expliciete registraties
behouden in AppServiceProvider::boot(), auto-discovery globaal uit via
->withEvents(discover: false) in bootstrap/app.php. Reden: explicit >
implicit voor observability-kritische bindings — grep-baar, IDE-
navigeerbaar, direct zichtbaar bij code review.

TagJobAttemptOnSentry registratie ook van class-string naar array-
callable vorm gebracht zodat event:list de gebonden methode toont
(consistent met AuthScopeContextListener-registraties).

Test count ongewijzigd op 1544. Larastan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:26:45 +02:00
adab3be781 fix: register AuthScopeContextListener for Sanctum bearer-token flow
Live HTTP smoke test on the post-architectural-fixes branch surfaced
that captured Sentry events carried only route-scope tags (app,
route_name, http.method) — auth-scope tags (user_id, actor_type,
actor_scope) were absent on every request.

Root cause: Sanctum's Guard fires Laravel\Sanctum\Events\TokenAuthenticated
(vendor/laravel/sanctum/src/Guard.php:77) on bearer-token resolution,
NOT Illuminate\Auth\Events\Authenticated. The Authenticated event only
fires from SessionGuard
(vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php:833),
which Crewli does not use — CookieBearerToken middleware injects the
httpOnly cookie as Authorization: Bearer, then auth:sanctum invokes
Sanctum's Guard. So the listener never ran on Crewli's HTTP path.

Offline tests in AuthScopeContextListenerTest passed because they
dispatch event(new Authenticated(...)) directly, bypassing the Guard
layer. Sanctum::actingAs() in tests has the same blind spot — it
short-circuits the Guard via guard('sanctum')->setUser() and fires
neither event.

Fix:
- New handleTokenAuthenticated(TokenAuthenticated $event) method on
  AuthScopeContextListener extracts the user via $event->token->tokenable
  and delegates to a private bindForUser() shared with handle().
- AppServiceProvider registers the listener for both Authenticated
  (covers SessionGuard / login flow / future authenticators) and
  TokenAuthenticated (covers Crewli's bearer-token Sanctum flow).

Regression coverage: AuthScopeBindingHttpFlowTest exercises the real
Sanctum Guard via $user->createToken() + Authorization: Bearer header.
Three cases:
  - super_admin on a user-scope route: actor_scope=user, all auth tags
    present.
  - super_admin on an admin.* route: actor_scope=platform, no
    organisation_id (correct platform-mode behaviour).
  - org_admin on a route with {organisation} param: actor_scope=
    organisation, organisation_id valid ULID.

Test count 1541 to 1544. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:58:42 +02:00
0379016c7e docs: WS-7 PR-2 follow-up — RFC §3.6 + §3.14 + BACKLOG OBS entries
RFC §3.6 — context tagging tabel volledig vervangen na de PR-2 follow-up
architecturale fixes. Belangrijkste wijzigingen:
- Tag-binding gesplitst in route-scope (BindSentryRouteContext middleware)
  en auth-scope (AuthScopeContextListener op Authenticated event).
- Nieuwe actor_scope tag (organisation/platform/user/anonymous).
- Multi-tenant invariant verfijnd: organisation_id is altijd correct
  gerelateerd aan actor_scope in plaats van "altijd aanwezig". Platform-
  routes zonder org-context worden niet meer gefabriceerd; default
  authenticated user-scope omitt organisation_id (Crewli's User<->Organisation
  is many-to-many, geen reliable single-org hint).
- impersonation.* tags expliciet gedocumenteerd als afkomstig uit
  HandleImpersonation middleware (post-swap), niet uit auth-listener.
- ActorType waarden bijgewerkt na verwijdering van VOLUNTEER case.

RFC §3.14 — status-note toegevoegd dat D-06 indexes al via Spatie's
nullableMorphs default-migratie zijn aangemaakt, met regression-guard
verwijzing.

§6 acceptance criterium 12 markeert D-06 als al voldaan.

BACKLOG.md krijgt vier nieuwe OBS-entries:
- OBS-1: VOLUNTEER actor_type promotion wanneer rol komt
- OBS-4: PHPUnit metadata deprecation cleanup pre-PHPUnit-12
- OBS-6: sentry-laravel install gap awareness + bootstrap test
- OBS-7: custom render handlers report() invariant + coverage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:05:42 +02:00
eb8202584c test: ActivityLogIndexesTest regression guard for D-06
PR-2 verified that Spatie's activitylog default migration creates the
composite indexes RFC-WS-7 §3.14 / addendum D-06 require — via
nullableMorphs('subject') and nullableMorphs('causer'), which emit
indexes named `subject` on (subject_type, subject_id) and `causer` on
(causer_type, causer_id).

This test queries information_schema.STATISTICS and fails if either
composite is missing, regardless of the index name. It guards against
silent regression when:
  - A future Spatie major release changes nullableMorphs semantics.
  - A developer rewrites the activity_log migration without preserving
    the morph indexes.
  - A schema-dump regeneration drops them.

Test count 1539 to 1541. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:00:07 +02:00
49cece3784 feat: actor_scope tag + tenant fallback resolution chain
PR-2 live smoke test surfaced that super_admin platform-route
exceptions arrived without organisation_id, and the original RFC §3.6
invariant (always-present organisation_id on authenticated events)
would force misleading attribution if it tried to fill that gap.

Refined invariant: every authenticated event carries actor_scope
(organisation/platform/user/anonymous), AND when actor_scope is
organisation, organisation_id MUST be a valid ULID. Platform-mode
correctly omits organisation_id rather than fabricate one.

Resolution chain in AuthScopeContextListener:
  1. {organisation} or {event} URI parameter -> actor_scope=organisation
  2. portal_event request attribute -> actor_scope=organisation
  3. super_admin on admin.* named route -> actor_scope=platform
     (Crewli's platform-admin routes use the admin. name prefix)
  4. Default authenticated -> actor_scope=user, no org tag
     (User<->Organisation is many-to-many; no reliable single-org hint)

Eight new test cases in AuthScopeContextListenerTest cover each branch
and the conditional invariant, including ULID validity via
Symfony\Component\Uid\Ulid::isValid.

Test count 1531 to 1539. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:57:12 +02:00
9414d09472 refactor: BindSentryContext to AuthScopeContextListener for auth-scope tags
Sentry-context binding split into two responsibilities:

- Route-scope (app, http.method, route_name) stays in middleware on
  the api group as BindSentryRouteContext — works on every request,
  no auth required.
- Auth-scope (user_id, actor_type) moves to AuthScopeContextListener
  on Illuminate\Auth\Events\Authenticated — works on every
  authentication mechanism (Sanctum, portal-tokens, future
  authenticators) without per-route middleware-attachment. Listener
  also augments Log::withContext with user_id (closes OBS-2).

Architecturally fault-preventing rather than fault-detecting: new
authenticated route groups need no separate sentry.context aliasing,
so silent observability gaps are no longer possible (closes OBS-3).

Impersonation tagging is co-located with HandleImpersonation: after
the user-swap, the middleware re-tags Sentry scope with the target
user_id/actor_type and adds impersonation.active /
impersonation.impersonator_user_id / impersonation.session_id. The
Authenticated event fires for the admin (Sanctum's natural flow),
the listener tags the admin, then HandleImpersonation overwrites
post-swap.

Files renamed:
- BindSentryContext -> BindSentryRouteContext (route-scope only)
- BindSentryContextTest -> BindSentryRouteContextTest (4 cases)

Files added:
- AuthScopeContextListener
- AuthScopeContextListenerTest (6 cases)

bootstrap/app.php drops the sentry.context alias and prepends
BindSentryRouteContext to the api group. routes/api.php drops every
sentry.context middleware string from auth:sanctum groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:53:14 +02:00
42994522eb refactor: drop ActorType::VOLUNTEER pending volunteer role introduction
VOLUNTEER was reserved-but-unused. Resolver mapped non-admin
authenticated users to ORG_MEMBER because Crewli has no dedicated
volunteer Spatie role; volunteer-ness is behaviour (shift assignments),
not identity.

Dead enum cases are YAGNI violations under zero-compromise: a future
developer could use the case without realising no resolution path
leads to it, producing a silent no-op. Re-introduce alongside a real
volunteer role split when that lands (BACKLOG OBS-1).

ActorType keeps ORGANIZER_ADMIN, SUPER_ADMIN, PORTAL_TOKEN, ORG_MEMBER,
UNAUTHENTICATED. Tests at 1537, Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:43:48 +02:00
5980c36ae4 refactor: SentryEventScrubber static + config array notation
The scrubber is fully stateless. Container-resolution per event was
overhead without value, the closure indirection polluted the config
layer with executable logic, and stack traces showed an anonymous
closure frame instead of the class name.

- SentryEventScrubber::scrub() and its private helpers all become
  static methods. No instance fields, so the change is mechanical.
- config/sentry.php before_send switches from a closure that calls
  app() to PHP array-callable notation [Class, method]. Symfony
  OptionsResolver accepts array-callables for static methods.
- PiiScrubbingTest swaps (new SentryEventScrubber)->scrub(...) for
  SentryEventScrubber::scrub(...). Semantics unchanged.

Tests 1537 unchanged. Larastan and Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:42:25 +02:00
48f2a00564 fix: route controller exceptions through sentry-laravel reporter
PR-2 follow-up. The PR-2 backend SDK install passed unit tests because
they exercised the scrubber and the BindSentryContext scope writer in
isolation, but live exceptions from controllers never reached
GlitchTip — they were correctly logged to laravel.log but the report()
call had no Sentry-aware reporter to invoke.

Root cause: sentry-laravel 4.x does NOT auto-register an exception
reporter. The host application is required to wire Integration::handles
inside withExceptions in bootstrap/app.php (per the package README and
Sentry docs). Without it, report and Laravels automatic
report-before-render flow only hit the default log channel.

Fix: add Integration::handles at the top of withExceptions so
sentry-laravel registers a reportable callback that calls
captureUnhandledException for every reported throwable. Filtering
remains downstream:
  - ignore_exceptions in config/sentry.php drops Validation,
    Authentication, Authorization (RFC §3.10).
  - SentryEventScrubber::scrub returns null for sub-500 HttpException
    via the before_send hook (RFC §3.7).

Regression coverage: tests/Feature/Observability/ExceptionReportingTest
installs a real Sentry client with a recording before_send and exercises
the full request to capture pipeline through the auth and sentry.context
middleware. Five cases: RuntimeException IS captured (with §3.6 tags
attached), ValidationException is not, NotFoundHttpException 404 is
not, AuthorizationException 403 is not, request-context tags ride along
on the captured event.

Test count: 1532 to 1537. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:58:26 +02:00
4a8bb97764 feat: BindRequestLogContext middleware + X-Request-Id round-trip
WS-7 PR-2 commit 3. RFC §3.13.

- app/Http/Middleware/BindRequestLogContext.php: tags every Laravel log
  line written during the request with request_id, organisation_id,
  user_id, and route name. Sets X-Request-Id on the response so the
  SPA can correlate to backend log lines via one click.
- Client-supplied X-Request-Id is honoured only if it parses as a ULID
  via Str::isUlid. Junk input (empty, non-ULID) is rejected and a
  fresh ULID is generated server-side.
- Registered as a global api-group middleware via the prepend list so
  it runs before authentication. Unauthenticated 4xx responses still
  carry the X-Request-Id header.
- Test count: 1523 to 1532. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:28:50 +02:00
b1d5bcda76 feat: BindSentryContext middleware + queue job attempt tagging
WS-7 PR-2 commit 2.

- app/Http/Middleware/BindSentryContext.php: sets RFC §3.6 tags on the
  active Sentry scope (app, http.method, route_name, actor_type,
  user_id, organisation_id, event_id, impersonation). Multi-tenant
  invariant: throws RuntimeException in local/testing when an auth
  request to a tenant-scoped route lacks organisation_id; logs a
  warning in production so the user flow still completes.
- app/Listeners/Observability/TagJobAttemptOnSentry.php: tags
  queue.attempt on the scope from the JobProcessing event. Default
  stack-trace grouping preserved per §3.11.
- ActorType: VOLUNTEER case reserved for a future role split. Current
  resolver maps non-admin authenticated users to ORG_MEMBER.
- bootstrap/app.php: registers sentry.context alias. Applied inside
  auth:sanctum groups in routes/api.php so it runs after auth.
- AppServiceProvider::boot registers the queue listener.

Test count: 1507 to 1523. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:55 +02:00
bdb89a2479 feat: sentry-laravel install + scrubber + ignored exceptions
WS-7 PR-2 commit 1. Wires sentry-laravel into the app behind a
config-only no-op when SENTRY_DSN_BACKEND is empty (RFC §3.3).

- composer require sentry/sentry-laravel ^4.15 (resolved 4.25.1)
- config/sentry.php: DSN env mapped to SENTRY_DSN_BACKEND, environment
  falls back to APP_ENV, traces/profiles forced to 0.0 (RFC §2
  amendment B), send_default_pii hard-pinned false, before_send to
  SentryEventScrubber, ignore_exceptions covers ValidationException /
  AuthenticationException / AuthorizationException.
- app/Services/Observability/SentryEventScrubber.php: recursive body /
  header / query-string scrubber + form_values wholesale replacement +
  HttpException sub-500 drop (status filter that ignore_exceptions
  cannot do class-only). Max-depth guard against malicious payloads.
- app/Enums/Observability/ActorType.php: enum + resolver for §3.6
  actor_type tag (consumed by BindSentryContext in commit 2).
- tests/Feature/Observability/PiiScrubbingTest.php: 20 cases.
- api/.env.example: SENTRY_DSN_BACKEND + SENTRY_RELEASE entries.

Larastan: clean. Test count: 1487 to 1507.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:55:50 +02:00
d4b785a2c9 chore: add WS-7 observability docs to sync manifest 2026-05-06 08:41:45 +02:00
932788c643 docs: glitchtip runbook + setup + RFC §3.1 dev amendment
Operational docs for the GlitchTip stack landed in the previous two
commits.

- dev-docs/GLITCHTIP.md: new runbook covering local dev, project
  provisioning + DSN-to-vault flow, production deploy on
  monitoring.hausdesign.nl (DNS, DirectAdmin Let's Encrypt, Apache
  reverse proxy with WS upgrade), backup install + restore drill,
  smoke tests, troubleshooting.
- dev-docs/SETUP.md: services table now includes GlitchTip; new
  docker/glitchtip/.env subsection points at the runbook.
- dev-docs/RFC-WS-7-OBSERVABILITY.md §3.1: amended to record that the
  same compose file drives local dev (Mailpit at bm_mailpit:1025), so
  prod and dev cannot drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:15:27 +02:00
5f6fc075ed feat: glitchtip postgres backup script
Daily pg_dump → gzip → retention pipe for the GlitchTip database.
Configurable via env vars (defaults: ./backups/glitchtip, 30-day
retention, glitchtip-postgres container). Streams directly through
gzip so no plaintext dump touches disk; output 0600.

Cron example in the script header. RFC-WS-7-OBSERVABILITY §5
acceptance criterion 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:13:46 +02:00
fc5a2a9156 feat: glitchtip docker stack + local dev integration
WS-7 PR-1 — bring up self-hosted GlitchTip alongside the existing
dev stack. One compose file is portable to the production monitoring
host (RFC-WS-7 §3.1).

- docker-compose.glitchtip.yml: web/worker/postgres/redis pinned, web
  bound to 127.0.0.1:8200, internal network for postgres + valkey.
- docker/glitchtip/.env.example: documented dev defaults + production
  checklist; .env itself ignored.
- Makefile: services / services-stop merge both compose files; new
  services-glitchtip-status tail target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:12:31 +02:00
25888a232b Updated the URL of Glitchtip 2026-05-06 07:42:09 +02:00
d31cdf9392 chore: gitignore claude code runtime lock files 2026-05-06 07:35:53 +02:00
1e394879aa docs: RFC-WS-7 observability foundation (GlitchTip)
Two charter amendments from the original WS-7 brief:

- Sentry -> GlitchTip (self-hosted, protocol-compatible). Same
  Sentry SDKs on backend (sentry-laravel) and frontend
  (@sentry/vue), pointed at a self-hosted GlitchTip DSN. Avoids
  Sentry SaaS pricing and keeps event data on infrastructure
  Bert controls.
- Performance monitoring out of scope (errors-only). WS-7
  delivers exception capture + alerting + scrubbing + RBAC
  only. APM/tracing/spans deferred to a later workstream if
  ever needed; pre-launch with no users, the cost/benefit
  doesn't justify it now.

RFC-as-first-commit pattern (per WS-6) so the scope-alignment
document is in main before any infra/code changes land.
2026-05-06 07:32:12 +02:00
f41951ae69 Merge pull request 'WS-3 PR-C: doc-state reckoning + apps/portal sweep' (#7) from chore/ws-3-pr-c-doc-cleanup into main
Reviewed-on: #7
2026-05-06 02:35:44 +02:00
1437829501 chore(backlog): close TECH-DOCS-APPS-PORTAL-PURGE
WS-3 PR-C delivered the per-file DELETE/REWRITE/KEEP_AND_PURGE
matrix on all 9 files referenced in the entry, plus the
out-of-scope post-edit-eslint.sh hook fix. Doc-rot removed:
~80 KB of obsolete bootstrap and prompt-template content.

Single SPA, single cookie, single deploy host. WS-3 complete.
2026-05-06 02:14:46 +02:00
d33c119d75 chore(docs): delete obsolete bootstrap and prompt-template docs
Five files removed, all describing project states that no longer
apply post-WS-TOOLING-001:

- .cursor/instructions.md (8.4 KB): Phase 1-4 roadmap with all
  checkboxes empty; Phase 1 has been done for ~6 months. Broken
  'make portal' target. Content overlaps with CLAUDE.md.
- .cursor/ARCHITECTURE.md (18.9 KB): pre-WS-3 framing (dual SPA,
  dual cookies, dual SANCTUM_STATEFUL_DOMAINS) AND pre-Form-Builder
  schema (volunteer_profiles, public_forms with JSON fields). All
  six sections superseded by SCHEMA.md, AUTH_ARCHITECTURE.md,
  design-document.md, API.md, 102_multi_tenancy.mdc.
- dev-docs/MASTER_PROMPT_CC.md (13 KB): 'paste this above every task'
  workflow superseded by auto-loaded CLAUDE.md and structured
  Claude Chat-authored prompts. Stale dual-SPA + pre-Form-Builder
  assumptions throughout.
- dev-docs/MASTER_PROMPT_CURSOR.md (7.5 KB): same workflow obsoletion;
  Cursor is now IDE-only (Claude Code does all implementation).
  .cursor/rules/ system handles IDE-level guidance.
- dev-docs/dev-guide.md (32 KB): bootstrap-from-scratch document
  containing embedded snapshots of pre-Form-Builder CLAUDE.md,
  pre-Form-Builder SCHEMA.md, pre-Form-Builder API.md as
  copy-paste templates. Section 5 prompts pre-WS-TOOLING-001 era.
  Section 6 (agents) overlaps with CLAUDE_CODE_TOOLING.md.

Total: ~80 KB doc-rot removed.

Cross-reference check found four files outside the deleted set
referencing the deleted paths; all updated in the same commit:

- README.md: Documentation table rebuilt around CLAUDE.md +
  dev-docs/* (also dropped stale resources/design/ row pointing
  at a directory that no longer exists, and corrected docs/*
  paths to dev-docs/*)
- dev-docs/CLAUDE_DESKTOP_SETUP.md: dropped MASTER_PROMPT_CC,
  MASTER_PROMPT_CURSOR, dev-guide entries from the
  bewust-verwijderd exclusion list; updated Gerelateerd pointer
  from dev-guide.md -> SETUP.md
- dev-docs/ARCH-CONSOLIDATION-2026-04.md: updated future-distribution
  pointer from dev-guide.md -> SETUP.md (sprint briefing is
  historical so the change is purely doc-hygiene)
- dev-docs/VIBE_CODING_CHECKLIST.md: removed Dev guide row from
  the bestandspaden table

Remaining references in dev-docs/BACKLOG.md (lines 862-869) live
inside the TECH-DOCS-APPS-PORTAL-PURGE entry that closes in the
next commit.

Canonical replacements: dev-docs/SETUP.md (rewritten this PR),
CLAUDE.md, CLAUDE_CODE_TOOLING.md, and the ARCH-*.md series.
2026-05-06 02:14:10 +02:00
2c4d2257ae chore(hooks): drop apps/portal scope from post-edit-eslint.sh
Three pre-WS-3 references purged: regex (apps/(app|portal)),
grep (apps/(app|portal)), and the now-obsolete "apps/portal/ is
planned but not present" defensive comment. The $spa variable
becomes redundant with only one SPA — collapsed to direct
apps/app/ references.

Net: simpler script, no behavioural change for actual files in
apps/app/ (still runs pnpm eslint --fix). Files outside apps/app/
were already a no-op.
2026-05-06 02:13:01 +02:00
bea66a58e6 chore(docs): purge apps/portal mention from CLAUDE_CODE_TOOLING.md
Single-line fix in the hooks reference table. The post-edit-eslint
hook used to scope to apps/app/ or apps/portal/; post-WS-3 there's
only apps/app/.

Code change in the hook script itself lands in the next commit.
2026-05-06 01:51:37 +02:00
451eab42ac chore(rules): purge apps/portal from 102_multi_tenancy.mdc
Surgical updates reflecting post-WS-3 single-SPA reality. The
OrganisationScope rules, three-level authorization, and invitation
flow are unchanged — they're still the canonical guidance.

Changes:
- globs: drop apps/portal/**/*.{vue,ts}
- Portal Architecture: "two access modes in apps/portal/" ->
  "two access modes under /portal/* routes within apps/app/"
- Token flow URL example: portal.crewli.app -> crewli.app/portal/
  with note about 301 redirect from legacy host
- Login flow URL: portal.crewli.app -> crewli.app
- CORS allowed_origins: drop FRONTEND_PORTAL_URL line
- Production example: collapse dual-host to single-host with
  pointer to AUTH_ARCHITECTURE.md §11 for the legacy env key
2026-05-06 01:51:18 +02:00
d82cf42728 chore(rules): rewrite 101_vue.mdc as slim principles file
Drops 17 KB of embedded code templates that had drifted from actual
implementations in apps/app/src/ (auth store template still used
localStorage; portal router guards still showed dual-mode logic that
was consolidated to /portal/* routes within apps/app in PR-B1/B2a).

Slim rewrite: principles + file structure + pointers to actual
reference code in apps/app/src/. Globs narrowed to apps/app/**/*
since apps/portal/ no longer exists. Vuexy component selection
deferred to dev-docs/VUEXY_COMPONENTS.md as canonical registry.

Net: ~17 KB -> ~3 KB, less drift surface, points at living code
instead of duplicating it.
2026-05-06 01:50:39 +02:00
5d4132785f chore(docs): rewrite SETUP.md as continue-existing-project guide
Replaces the bootstrap-from-scratch document (Step 2 told readers to
run 'composer create-project laravel/laravel api' on an existing
codebase) with a continue-existing-project guide.

Scope: prerequisites, first-time setup (clone + install + .env + migrate),
daily workflow (three terminals + optional queue worker), env-config
explained, common tasks (test/migrate/route:list/build), documentation
reference linking the dev-docs/ canonical files, troubleshooting.

Drops apps/portal references throughout (single SPA at port 5174).
Drops dual-port SANCTUM_STATEFUL_DOMAINS guidance. Replaces .cursor/
instructions reference with /CLAUDE.md as auto-loaded source of truth.
2026-05-06 01:50:01 +02:00
808ec212eb Merge pull request 'WS-3 PR-B2b: A13-3 + single-cookie + single-host (incl. flatpickr precursor)' (#6) from feat/ws-3-pr-b2b-single-cookie-deploy into main
Reviewed-on: #6
2026-05-06 01:16:05 +02:00
289e735fd6 chore(types): regenerate components.d.ts to sync with PR-B2a additions 2026-05-06 01:09:04 +02:00
eb485573ce chore(types): regenerate auto-imports.d.ts to sync with PR-B2a additions 2026-05-06 01:04:05 +02:00
ad23847050 fix(deps): import flatpickr CSS via JS, add flatpickr direct dep 2026-05-06 01:03:25 +02:00
7a69b03c78 chore(docs): drop apps/portal references from load-bearing files
Three load-bearing files still described the pre-WS-3 dual-SPA
reality. Surgical edits to reflect the single-SPA architecture
shipped in WS-3 PR-B (B1: portal moves; B2a: auth+routing
consolidation; B2b: server-side cookie consolidation).

CLAUDE.md:
- Quality-gates ts-reset bullet (line 27): "both SPAs" → "the SPA"
- Quality-gates Vitest bullet (lines 30-32): rewrite from "apps/portal
  has 113+ tests; apps/app currently has no Vitest setup (TECH-APP-VITEST)"
  to current truth: apps/app has Vitest with 213 tests as of PR-B2a.
  TECH-APP-VITEST is implicitly closed.
- Repository layout (line 44): drop apps/portal/ bullet; rephrase
  apps/app/ as the single workspace
- "Apps and portal architecture" → "App architecture": rewrite for
  single-workspace + two access modes. Login-based covers
  organizers + volunteers + crew + super_admin (context-routed
  in-app via useAuthStore.availableContexts); token-based covers
  artists, suppliers, press
- CORS subsection: collapse two-origin config to single origin
  (localhost:5174 dev, https://crewli.app prod). Preserve the
  existing crewli.nl marketing-only note

WS-TOOLING-001 sections (Larastan, Rector, Telescope tooling
configuration) verified untouched via `git diff CLAUDE.md`.

README.md (line 25): collapse the Applications table from two rows
(Organizer + Portal) to one (SPA). Adjust trailing sentence accordingly.

Makefile:
- .PHONY list: drop `portal`
- help echo: drop "make portal" line
- portal target: removed (the underlying `cd apps/portal && pnpm dev`
  would fail since the directory was removed in PR-B1)

Out of scope (deferred to TECH-DOCS-APPS-PORTAL-PURGE backlog item):
.cursor/ instructions, MASTER_PROMPT_*, dev-docs/SETUP, dev-docs/dev-guide,
dev-docs/CLAUDE_CODE_TOOLING. WS-3-SESSION-1C-AUDIT.md skipped (frozen
historical doc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:30:53 +02:00
812cc17460 docs(auth): reflect single-cookie architecture; close A13-3
dev-docs/AUTH_ARCHITECTURE.md (v1.0 → v2.0):
- Title section updated to single-SPA / single-cookie reality
- Client Applications table collapsed to one row
- Cookie Specification table collapsed to one row (crewli_app_token)
- Token Lifecycle / Validation section: Origin-based resolution
  language removed; middleware described as origin-agnostic
- Cross-app isolation paragraph removed (no second app)
- Configuration Reference table marks FRONTEND_PORTAL_URL as legacy,
  pointing at TECH-FRONTEND-URL-CONSOLIDATE
- New §11 "History" preserves the pre-WS-3 dual-cookie context for
  future readers, mentions PR-B2a + PR-B2b roles in the unwind

dev-docs/BACKLOG.md — three new entries:
- TECH-FRONTEND-URL-CONSOLIDATE: refactor email controllers to drop
  per-app URL map (EmailChangeController, PasswordResetController,
  PersonController) — low priority, code-cleanliness only
- TECH-DOCS-APPS-PORTAL-PURGE: sweep apps/portal references from
  briefing/tooling docs (.cursor/, MASTER_PROMPT_*, SETUP, dev-guide,
  CLAUDE_CODE_TOOLING) — single chore(docs) PR, low priority
- OPS — DNS retirement of portal.crewli.app — operational task,
  deferred until traffic monitoring confirms zero usage

dev-docs/SECURITY_AUDIT.md:
- A13-1 narrative actualised: pre-WS-3 dual-cookie context kept as
  history, status flipped to RESOLVED (the localStorage→httpOnly
  migration shipped earlier in the consolidation arc)
- A13-3: status flipped to RESOLVED by WS-3 PR-B2b; description
  rewritten to reflect the new postLoginRedirect.ts validator and
  the 16 spec coverage
- Priority remediation table item 8 strikes through A13-3

Backend test suite: 1487 passed (unchanged from Commit 2 baseline).
Frontend: 223 passed (unchanged from Commit 1 baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:29:26 +02:00
a748c9ee7a chore(deploy): single-host deploy config — drop apps/portal build, retire portal.crewli.app
deploy.sh referenced apps/portal which was deleted in WS-3 PR-B1; the
script has been broken in main since that merge (npm run build
-w apps/portal would fail). Collapse to a single-app build.

Changes:
- deploy.sh: replace dual-build block (build app + portal, verify both
  dist/) with single-app build (build app, verify dist/index.html)
- deploy/nginx/csp-portal.conf: deleted (content was identical to
  csp-spa.conf — verified before removal)
- deploy/README.md: replace "Portal (portal.crewli.app)" server-block
  section with "Legacy portal redirect" — a 301 server block
  template that redirects portal.crewli.app → crewli.app preserving
  the request URI. Notes that DNS retirement is a separate ops task

Out of scope: actually retiring the portal.crewli.app DNS record
(operational, tracked separately).

bash -n deploy.sh: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:24:40 +02:00
2e94a107e4 refactor(auth): consolidate to single cookie post single-SPA
The dual-cookie machinery (crewli_app_token + crewli_portal_token,
Origin-based resolution) was load-bearing only when the second SPA
existed. apps/portal/ was deleted in WS-3 PR-B1; the resolver code
has been carrying dead branches since then. Collapse to one cookie.

Cookie name retained as crewli_app_token — no session breakage on
deploy. crewli_portal_token is fully purged from the server-side.

CookieBearerToken middleware:
- COOKIE_NAMES array → single COOKIE_NAME constant
- resolveCookieName method (Origin/Referer parsing, host+port
  matching against frontend_app_url/frontend_portal_url) → removed
- Body collapses to: skip if Authorization header present; else
  read crewli_app_token cookie and inject Bearer header

SetAuthCookie trait:
- COOKIE_MAP / resolveCookieName / originMatches → removed
- makeAuthCookie / forgetAuthCookie now take only $token; the
  cookie name is the trait's private constant

Five callers updated to drop the resolveCookieName($request) line
and the cookie-name argument: LoginController (3 sites),
MfaVerifyController (1 site), AuthRefreshController (1 site),
LogoutController (1 site), InvitationController (1 site — caller
list in the prompt missed this one but the same pattern applies).

frontend_portal_url config key retained (per Phase A directive Q1):
EmailChangeController, PasswordResetController, PersonController are
non-auth consumers that build per-app URL maps for outbound emails.
The map structure is now functionally redundant (production resolves
all FRONTEND_* env vars to the same host) but stays structurally
intact. Refactor tracked as TECH-FRONTEND-URL-CONSOLIDATE in the
upcoming docs commit.

HttpOnlyCookieAuthTest:
- Removed 4 dual-cookie tests (login_sets_portal_cookie_for_portal_origin,
  app_cookie_does_not_authenticate_portal_requests,
  portal_cookie_does_not_authenticate_app_requests,
  correct_cookie_authenticates_with_matching_origin)
- Renamed login_sets_app_cookie_for_unknown_origin →
  login_sets_app_cookie_regardless_of_origin; expanded to four
  Origin variants (none, app, unknown, foreign) — pins the new
  origin-agnostic contract
- Removed Origin headers from request calls in remaining tests
  (now meaningless)

Backend test count: 1491 → 1487 (-4 deleted, dual-cookie tests
encoding the obsolete contract). Pint clean. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:24:01 +02:00
96cb1519de feat(security): full A13-3 open-redirect validation in postLoginRedirect
Replaces the WS-3 PR-B2a minimum precaution (`startsWith('/') &&
!startsWith('//')`) with a layered validator that rejects every input
that is not a strict relative path.

isSafeRelativePath rejects:
- Empty / null / undefined input
- Non-`/`-prefixed paths (including leading whitespace)
- Protocol-relative URLs (`//evil.com`)
- Backslash anywhere (browsers normalise `\` → `/` in some contexts;
  `/\evil.com` parses as `//evil.com`)
- ASCII control characters `\x00`–`\x1F` and `\x7F` (NUL, tab, LF, CR,
  DEL, etc. — header-injection vectors)
- Anything the URL constructor parses to a different origin than the
  synthetic invalid origin used as the resolution base

The URL-constructor check is the authoritative guard; the prefix and
character checks are fast pre-filters that short-circuit common
attack shapes without paying the URL allocation.

Test coverage expands from 6 → 16 cases. New cases pin the
backslash, control-character, leading-whitespace, and positive-
character-set contracts. The URL-encoded-slash-in-query case
documents that we don't false-positive on `%2F` in query strings.

Closes A13-3 (open-redirect on post-login).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:20:12 +02:00
538072241e docs(backlog): WS-TOOLING-001 done + 3 follow-up tech items 2026-05-06 00:08:09 +02:00
ad36c06208 Merge WS-TOOLING-001: Claude Code deterministic guard-rail layer 2026-05-05 23:58:32 +02:00
9ae3e6a757 chore(claude): ignore worktrees and macOS metadata 2026-05-05 23:58:00 +02:00
b1773ea1f3 docs(claude): document rm -rf rule in hooks reference table 2026-05-05 23:56:21 +02:00
735ba2c8d9 docs: add CLAUDE_CODE_TOOLING.md and cross-reference from CLAUDE.md
Documents the deterministic guard-rail layer: hooks reference,
crewli-reviewer subagent usage, three slash commands, how to test
hooks, how to disable temporarily, and the binding design principle
(settings/hooks deterministic, CLAUDE.md advisory — never duplicate).
2026-05-05 23:25:03 +02:00
05d1a6d31d chore(claude): add sprint-status, review-multitenancy, sync-docs commands
/sprint-status — branch, last package, uncommitted work, next BACKLOG item.
/review-multitenancy <Model> — model+migration+policy+tests checklist.
/sync-docs — runs the dev-docs sync pipeline and reminds to upload .claude-sync/.

Each command's frontmatter declares a least-privilege allowed-tools list.
2026-05-05 23:24:58 +02:00
ff4f9a9dbb chore(claude): add crewli-reviewer subagent
Isolated-context code review against the zero-compromise principles.
Read/Grep/Glob/Bash only — no Edit, so the reviewer cannot patch
code. Outputs MUST FIX / SHOULD FIX / CONSIDER, every finding cited
as path:line.
2026-05-05 23:24:52 +02:00
1e65a65b20 chore(claude): add SessionStart compact context injector
inject-sprint-context.sh fires on SessionStart with matcher=compact
and emits branch, last 10 commits, and the top of BACKLOG.md so
Claude resumes with sprint context after auto-compaction. Output
capped at ~600 tokens.
2026-05-05 23:24:46 +02:00
f7ef26d450 chore(claude): add pint and eslint PostToolUse hooks
post-edit-pint.sh runs vendor/bin/pint --dirty from api/ after any
.php edit. post-edit-eslint.sh runs pnpm eslint --fix inside the
matching SPA dir for .vue/.ts/.tsx/.js files under apps/app/ or
apps/portal/. Both exit 0 unconditionally — formatting failures must
not block the agent.
2026-05-05 23:24:41 +02:00
da42dbb2dd chore(claude): add protect-files and block-dangerous-bash PreToolUse hooks
protect-files.sh blocks Edit/Write to secrets, lock files, default
Laravel migrations, the deleted apps/admin/ tree, .claude/ itself,
and dev-docs/SCHEMA.md.

block-dangerous-bash.sh blocks destructive git operations, blanket
dependency updates, and database wipes that aren't scoped to the
testing environment.

Both signal block via exit 2 with a reason on stderr; both stay well
under 500ms per invocation.
2026-05-05 23:24:32 +02:00
18fb035c23 chore(claude): add settings.json with hook registry
Registers PreToolUse, PostToolUse, and SessionStart hooks for the
deterministic guard-rail layer. settings.local.json stays gitignored
for per-user overrides.
2026-05-05 23:24:08 +02:00
68f1e6f80c Merge pull request 'WS-3 PR-B2a: auth + routing consolidation (single SPA, dual axios, context-aware guards)' (#5) from feat/ws-3-pr-b2a-auth-routing-consolidation into main
Reviewed-on: #5
2026-05-05 22:43:52 +02:00
145d0cbdad docs(backlog): add ARCH-API-RESPONSE-VALIDATION workstream entry
Workstream-sized item geborgt voor uniforme typed + runtime-validated
contracts op de API-grens (backend PHP Enums, frontend Zod schemas,
codegen TS types). Scope, sequentie (post-PR-C/WS-7, pre-RFC-FORM-BUILDER-UI),
en open beslissingen vastgelegd. Verwijst naar dev-docs/ARCH-API-VALIDATION.md
skeleton voor architectuur-detail.

Voorkomt dat S3b technische schuld stapelt — landt vóór RFC-FORM-BUILDER-UI
zodat nieuwe composables vanaf dag één het gevalideerde patroon consumeren.
2026-05-05 22:32:05 +02:00
b3fb617985 chore(sync): track ARCH-API-VALIDATION.md in .claude-sync.conf 2026-05-05 22:17:45 +02:00
babbbd97cb docs(arch): add ARCH-API-VALIDATION.md skeleton — uniform API response validation workstream 2026-05-05 22:17:27 +02:00
b191fbe917 refactor(auth): migrate MfaChallengeCard to useAuthStore.verifyMfa
The card consumed the API directly via useVerifyMfa() (TanStack Query
mutation). Per Decision F's intent (store owns business logic, the
component consumes typed results), the card now calls
useAuthStore.verifyMfa() and pattern-matches on the MfaVerifyResult
discriminated union.

Changes:
- MfaChallengeCard: drop useVerifyMfa import; call authStore.verifyMfa
  with camelCase args (sessionToken, trustDevice, deviceFingerprint,
  deviceName); local isVerifying ref replaces verifyMutation.isPending.
  On result.kind === 'authenticated' emit `verified` (no payload —
  the store has already refreshed user state); on 'failed' surface
  result.reason with a generic fallback.
- emit signature: `verified: [data: unknown]` → `verified: []`.
- login.vue: onMfaVerified no longer calls authStore.refreshUser —
  authStore.verifyMfa() refreshes internally. Page just routes to
  resolvePostLoginTarget().

Adds 4 vitest specs in components/auth/__tests__/MfaChallengeCard.spec.ts
covering: success path emits `verified` with camelCase args, failure
path shows reason and suppresses emit, trustDevice toggle honours
fingerprint + device name, fallback message when reason is empty.

Test count 209 → 213. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:01:32 +02:00
eb7f3eb057 fix(portal): consume portal events from useAuthStore instead of duplicate /auth/me fetch
The auth-store merge made portal_events available on the unified
/auth/me response (held in useAuthStore.portalEvents). usePortalStore
now sources userEvents from the auth store, eliminating the duplicate
fetch that the legacy slim usePortalAuthStore had compensated for.

Changes:
- types/auth.ts: add portal_events?: PortalEvent[] to MeResponse
- useAuthStore: add portalEvents ref, populated in setUser from
  me.portal_events, cleared in clearState
- usePortalStore: replace loadUserEventsFromApiAndStorage (which
  fetched /auth/me) with syncEventsFromAuthStore (which reads
  authStore.portalEvents). A reactive watch keeps userEvents in sync
  whenever the auth store updates (login, refresh, logout). The
  sessionStorage merge stays as offline cache + post-registration
  bridge.
- types/portal.ts: drop the now-unused AuthMeUser type — MeResponse
  is the canonical shape post-merge.

Boundaries: usePortalStore (stores-portal) statically imports
useAuthStore (stores) — already allowed by the matrix
(stores-portal allow includes stores).

Adds 4 vitest specs covering: userEvents reflects auth.portalEvents,
no apiClient.get('/auth/me') call from the portal store,
sessionStorage fallback when auth has not hydrated, reactive update
on auth.portalEvents change.

Test count 205 → 209. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:57:40 +02:00
3019095a2a fix(security): A13-8 — migrate portal store to sessionStorage with explicit reset
usePortalStore now persists state in sessionStorage instead of
localStorage. Tab-close clears the session implicitly; explicit logout
+ 401 paths invoke reset() which iterates the `crewli:portal:` prefix
and removes every key (forward-compatible for future portal-namespaced
state).

Storage keys are renamed under the canonical prefix:
- crewli_portal_user_events_v1 → crewli:portal:events
- crewli_portal_active_event_id_v1 → crewli:portal:activeEventId

The single new prefix-clear function (clearStoragePrefix) replaces the
hand-listed key removals, so future portal-namespaced state additions
need no reset() change.

useAuthStore.handleUnauthorized() (the 401 interceptor target) is now
async and invokes clearAll() — the canonical session-cleanup hub —
restoring the portal-storage cleanup that the deleted
usePortalAuthStore.handleUnauthorized previously owned. The merge in
Phase E left this gap; this commit closes it.

Adds 7 vitest specs in stores/portal/__tests__/usePortalStore.spec.ts
covering: sessionStorage persistence, reset() prefix-iteration,
non-prefixed-key isolation, reactive state reset, useAuthStore.clearAll
+ handleUnauthorized integration.

Test count 198 → 205. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:43:40 +02:00
38a94c78e9 feat(auth): post-login landing route resolution per context
login.vue is rewritten to consume useAuthStore.login()'s discriminated
union — no more direct apiClient calls or branching on raw API response
shapes. The page maps result.kind to UI/routing decisions only:

- mfa-required → swap to MfaChallengeCard with the typed payload
- authenticated → resolvePostLoginTarget() (?to= relative, else
  auth.resolveLandingRoute())
- must-set-password → forward-compatible placeholder route
- failed → field-level errors + rate_limit message branch

resolveLandingRoute() now returns a string path instead of
RouteLocationRaw — the typed router accepts string-paths cleanly,
removes the cast at every call site, and lets useAuthStore.spec.ts +
guards.spec.ts assert the resolved path directly.

A13-3 minimum precaution lives in a new utility:
src/utils/postLoginRedirect.ts. The relative-only check
(`startsWith('/') && !startsWith('//')`) rejects absolute, protocol-
relative, javascript:, and data: schemes. Full domain validation lands
in WS-3 PR-B2b.

6 vitest specs in utils/__tests__/postLoginRedirect.spec.ts cover the
six rejection / passthrough scenarios.

Test count 192 → 198. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:40:32 +02:00
209e0ef682 feat(layout): context-switcher for multi-role users
Adds components/shared/ContextSwitcher.vue — a Vuetify menu-button
that renders only when useAuthStore.showContextSwitcher is true (i.e.
the user has both portal and organizer contexts available). Click
calls useAuthStore.setLastContext + resolveLandingRoute and pushes
the new route.

Wired into both layouts:
- PortalLayout.vue: navbar right section, before UserAvatarMenu
- DefaultLayoutWithVerticalNav.vue (organizer navbar host): before
  NavbarThemeSwitcher (OrganizerLayout.vue itself is a 10-line
  wrapper around DefaultLayoutWithVerticalNav, so the component
  wires into the actual navbar host).

Boundaries matrix update: components-shared now allows `stores` so
canonical shared chrome (ContextSwitcher, future global indicators)
can read useAuthStore directly without re-homing to
components/layout/. stores-portal stays disallowed for components-
shared by design — portal-specific state has no place in shared
chrome.

Adds 3 vitest specs covering: visibility gated by
showContextSwitcher, click invokes setLastContext + router.push.

Test count 189 → 192. Frontend lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:35:32 +02:00
473b22ac9e feat(router): context-aware guards with meta-driven role/context resolution
Rewrites plugins/1.router/guards.ts per ARCH-CONSOLIDATION §4.3. The
B1 portal-context carve-out is removed; portal/organizer routing is
now declarative via meta.context, role gates via meta.requiresRole.

Guard pipeline:
1. Initialize auth store on first navigation
2. Public routes pass through (authenticated user on guest-only path
   is bounced to resolveLandingRoute)
3. Auth required → /login?to=<path>
4. MFA setup gate → /account-settings?tab=security
5. requiresRole declarative check (replaces hardcoded /platform path
   prefix + isSuperAdmin)
6. Context routing — portal returns early, organizer falls through
   and sets lastContext
7. Org-selection check (organizer routes only)

Page meta updates (mechanical, idempotent):
- 4 portal pages: removed `requiresAuth: true` (auth is implicit)
- 4 pages: replaced `requiresAuth: false` with `meta.public: true`
  (registreren, wachtwoord-instellen, advance/[token],
  invitations/[token])
- 22 organizer pages: added `context: 'organizer'`
  (account-settings, events/**, organisation/form-failures/**,
  select-organisation, dashboard, events/index, members,
  organisation/{index,companies,settings})
- 8 platform pages: added `context: 'organizer'` +
  `requiresRole: 'super_admin'`
- 6 organizer pages had no definePage block — one was added with
  `context: 'organizer'`

Adds plugins/1.router/__tests__/guards.spec.ts (11 tests) covering
public passthrough, unauthenticated redirect, portal/organizer
context branching, declarative requiresRole, org-selection
redirect, MFA gate.

Test count 178 → 189 (11 new). Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:32:54 +02:00
f2b08ecb21 refactor(auth): merge usePortalAuthStore into useAuthStore with context-aware getters
usePortalAuthStore is deleted — its 114 lines were a slim wrapper over
the same /auth/me endpoint useAuthStore already consumes. The merged
store gains the full set of additions Bert specified for B2a:

State:
- availableContexts / defaultContext (from /auth/me contexts block)
- lastContext (localStorage-persisted)
- portalToken (in-memory only, for the bearer-axios flavour)

Getters: isPortalUser, isOrganizerUser, isPlatformAdmin (alias of
isSuperAdmin), showContextSwitcher, hasRole(), hasAnyRole().

Actions: login(), verifyMfa() — both return typed discriminated
unions so login.vue (Phase H) consumes results without branching on
raw API response shapes. setLastContext, setPortalToken,
resolveLandingRoute, clearAll. clearAll dynamically imports
usePortalStore.reset() to clear portal sessionStorage on session-end —
this is the canonical session-cleanup hub now that the merge has
happened.

5 source files migrated from usePortalAuthStore → useAuthStore. The
PortalLayout.spec.ts mock follows. The boundaries matrix gains a
single new edge (`stores → stores-portal`) replacing the deleted
stores-portal/usePortalAuthStore which previously owned that
cross-zone call.

Adds 16 vitest specs in src/stores/__tests__/useAuthStore.spec.ts
covering setUser context hydration, hasRole/hasAnyRole, lastContext
localStorage persistence, resolveLandingRoute precedence
(portal/organizer/super_admin/multi-role/forceContext/forbidden
fallback), portalToken state, and clearAll cleanup.

Test count 162 → 178 (16 new). Frontend lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:25:24 +02:00
13d7b18257 refactor(axios): split lib/axios.ts into factory + default + portal-token instances
The single axios.ts file becomes a directory with:
- factory.ts — createApiClient + the registerDefaultInterceptors /
  registerPortalTokenInterceptors seam (preserves the
  TECH-AXIOS-STORE-COUPLING decoupling — no store imports inside)
- default.ts — cookie-authenticated client (organizer + cookie-auth
  portal flows; existing 45 call sites resolve unchanged)
- portal-token.ts — Bearer-auth client for the artist-advance /
  supplier-intake flows (forward-compatible groundwork; no active
  consumers today)
- index.ts — re-exports apiClient + portalApiClient + the register* /
  createApiClient surface; the existing `import { apiClient } from
  '@/lib/axios'` continues to work directory-resolved.

The bindings plugin (plugins/3.axios-bindings.ts) now wires both
clients with a shared deps base + flavour-specific overrides. The
`getPortalToken` callback returns null until Phase E surfaces
`portalToken` on useAuthStore — no current consumers exercise the
Bearer path, so the null-return is intentional placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:18:55 +02:00
a2760ffd64 feat(auth): add contexts + platform.is_super_admin to /auth/me, factory role-category states
Additive enrichment to MeResource — existing fields untouched, MeTest stays green.
New fields:
- contexts.available: list<'portal'|'organizer'> derived from Person + Organisation memberships
- contexts.default: precedence super_admin > organizer > portal > fallback portal
- platform.is_super_admin: bool promoted from app_roles
- organisations[].roles: 1-element array form alongside the legacy scalar role,
  forward-compatible for the multi-role pivot work tracked in TECH-PIVOT-ROLES-MULTI

UserFactory gains volunteer(), orgAdmin(), volunteerAndOrganizer(), superAdmin()
state methods — codified role categories for reuse across future workstreams.

Adds forbidden.vue placeholder (PublicLayout) for the context-failure landing in
the upcoming guard rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:15:10 +02:00
b5a2140517 fix(sync): track ARCH-BINDINGS.md in .claude-sync.conf 2026-05-05 20:43:20 +02:00
d1503ceadf docs(vuexy): update VUEXY_COMPONENTS.md for post-PR-B1 single-SPA reality
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:33:33 +02:00
a4281df021 docs(arch): add ARCH-BINDINGS.md — canonical reference for FormBindingApplicator pipeline (WS-8a) 2026-05-05 20:22:11 +02:00
06b3a637b2 Merge pull request 'WS-3 PR-B1: Portal moves + routing wiring' (#4) from feat/ws-3-pr-b1-portal-moves into main
Reviewed-on: #4
2026-05-05 20:21:01 +02:00
0dceb437f3 refactor(register): drop auth-store dependency from success.vue, rely on query param
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:13 +02:00
4a4bd6c51e chore(monorepo): remove apps/portal — single SPA from this commit forward
Completes WS-3 PR-B1 charter §4.2: portal is fully consumed by
apps/app under /portal/** (authenticated portal routes) and
/register/** (public token-based form-fill). All portal source has
moved or been merged in earlier commits in this PR.

Adaptations from the original prompt's Phase F:
  - pnpm-workspace.yaml does not exist at the repo root (the monorepo
    isn't a pnpm workspace; each app has its own package.json /
    node_modules / scripts). No edit needed.
  - Root package.json has no `dev:portal` / `build:portal` scripts.
    No cleanup needed.
  - Skipped `pnpm -w build` — apps/app builds via its own scripts.

Deletes 384 portal files (build configs, layouts, plugins, vendored
@layouts, public/, dev/prod Dockerfiles, nginx.conf, env.d.ts,
themeConfig, tsconfig, package.json, lockfile, etc.). All authentic
portal logic is preserved in apps/app/src — verified by:
  - Vitest 23 / 162 passing
  - vue-tsc --noEmit clean
  - eslint clean (zero new errors / warnings)

NOT verified at this point: `pnpm build`. The build fails on a
pre-existing missing `flatpickr` stylesheet import in
src/@core/components/app-form-elements/AppDateTimePicker.vue —
present on main pre-PR, unrelated to this work, and tracked
separately. Reproduced on plain `main` without any of these changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:32:37 +02:00
a84742a01f chore(eslint): activate boundary sub-zones (TECH-WS3-BOUNDARIES-SUBZONES)
Adds the WS-3 §4.2 sub-zone classification to the apps/app
boundaries matrix:

- components-{shared,portal,organizer} alongside the legacy
  components type. components/{auth,settings} are folded into
  components-shared as the legacy cross-context home for MFA dialogs
  + PasswordRequirements (used by both organizer reset-password and
  portal wachtwoord-instellen / profiel).
- composables-forms (src/composables/forms/**) — pure form-runtime
  helpers reusable from organizer Form Builder later.
- stores-portal (src/stores/portal/**) — keeps the portal auth +
  portal store walled off from the organizer auth surface.
- pages-{register,portal,platform,organizer} alongside the legacy
  pages type — register pages cannot reach into stores or
  components-portal/-organizer; portal pages cannot reach
  components-organizer; organizer + platform pages cannot reach
  stores-portal or components-portal.

Cross-context edges are forbidden (organizer ↛ portal,
shared ↛ portal/organizer). Two pragmatic exceptions are documented
inline:
  - components-shared accepts the legacy auth/ + settings/ paths
    until PR-B2 cleanup re-homes them under shared/{auth,settings}/.
  - pages-register may read stores-portal because success.vue
    optionally enriches with the portal user when authenticated.
    PR-B2 may move success.vue into pages-portal so this drops.

Lint: 0 errors / 0 new warnings (only the pre-existing
boundaries v5→v6 deprecation warnings, which apply to all 19 rules
now). Tests: 23 / 162 pass. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:29:32 +02:00
5c689f42a0 feat(router): wire portal/register pages, portal-context guard carve-out, lint cleanup
Routing wiring (Phase D of WS-3 PR-B1):

- apps/app/src/plugins/1.router/guards.ts: add a single early-return
  carve-out before the org-selection redirect — `if (to.meta.context
  === 'portal') return`. Per ARCH-CONSOLIDATION-2026-04 §4.3,
  meta.context is the canonical contract; PR-B2 evolves the guards
  from this key to full context-aware logic (post-login landing,
  context-switcher, role checks).
- apps/app/env.d.ts: extend RouteMeta with the new layout names
  ('OrganizerLayout' | 'PortalLayout' | 'PublicLayout'), context,
  requiresAuth, requiresToken, navMode, navTitle.
- apps/app/typed-router.d.ts: regenerated by unplugin-vue-router to
  pick up portal/* and register/* route names.
- Page meta finalisation: portal pages have layout: 'PortalLayout',
  context: 'portal', preserving original requiresAuth + nav fields;
  register pages have layout: 'PublicLayout' + public: true (the
  apps/app guard convention for public routes, since meta.public is
  what the existing guard recognises).

Form-types restructure (boundaries cleanup):

- apps/app/src/composables/forms/types/formBuilder.ts → src/types/forms/
- apps/app/src/composables/forms/utils/{formValidation,validators}.ts
  → src/utils/forms/
- All `@/composables/forms/{types,utils}/*` imports rewritten across
  pages, components, composables, tests.
- This avoids a `types → composables` boundaries violation at
  src/types/formSchema.ts which re-exports primitives from the
  inlined form-schema. types/formSchema.ts now imports from
  @/types/forms/formBuilder which is in the same boundaries zone.

Lint cleanup for moved portal sources (apps/portal had no
.eslintrc.cjs; the migrated code now has to pass apps/app's stricter
config):

- axios.isAxiosError → named import { isAxiosError }
  (ClaimenTab, RoosterTab, profiel.vue)
- void schemaQuery.refetch() → schemaQuery.refetch()
  (register/[public_token].vue)
- if-then-else collapsed to single boolean return (formatFieldValue)
- :delay-on-touch-only="true" → delay-on-touch-only shorthand
  (FieldSectionPriority)
- ml-2 class → ms-2 (FieldAvailabilityPicker)
- multi-statement-per-line splits in profiel.vue + spec files
- unused emailConfigured ref removed (profiel.vue)
- one-component-per-file disabled with TODO TECH-WS3-PORTAL-LINT-CLEANUP
  ref (FieldOptionsLocale.spec.ts — multi-Wrapper test pattern)
- restored `import Draggable from 'vuedraggable'` after lint:fix
  removed it (template-only usage; the import IS needed)
- camelcase param renamed in FieldOptionsLocale harness factory
- typecheck nudge: spec state.data typed via PublicFormSectionOption[] /
  PublicFormTimeSlot[] aliases instead of Record<string, unknown>
- PortalLayout.vue: explicit `import { useRoute, useRouter }` so the
  vitest mock can intercept (the trimmed AutoImport set doesn't pull
  vue-router's auto-imports)

Vitest: 23 / 162 passing. Lint: 0 errors / 0 new warnings (only the
pre-existing boundaries v5→v6 deprecation warnings remain). Typecheck:
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:26:46 +02:00
e3452312d1 refactor(layouts): merge portal navbar/drawer into PortalLayout.vue
Migrates the navbar (event/platform two-mode toggle), mobile drawer
with avatar header + logout, RouterView Suspense wrapper, and footer
from apps/portal/src/layouts/portal.vue into the PortalLayout.vue
skeleton from PR-A. The skeleton's structure (VApp / VAppBar / VMain
/ VFooter) is preserved as the outer shell.

Notable adaptations:
  - useAuthStore → usePortalAuthStore (renamed in C.3)
  - usePortalStore import path → @/stores/portal/usePortalStore
  - mobile nav links now point at /portal/evenementen and /portal/profiel
    (the new sub-zone paths) instead of /evenementen and /profiel
  - explicit `import { useRoute, useRouter }` from vue-router so the
    vitest mock can intercept (auto-import not configured for these in
    the trimmed test config)

Updated PortalLayout.spec.ts to mock the two pinia stores plus
useSkins, vue-router, UserAvatarMenu, and AppLoadingIndicator. Tests
now assert the auth-conditional rendering: header + drawer hidden
when unauthenticated, main + footer always present.

Also pulls in the @form-schema → @/composables/forms/* import
rewrites in the C.4-moved composables that the previous commit's
rename-only diff left unstaged.

Vitest: 23 files / 162 tests, no errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:11:58 +02:00
7282861a7e refactor(portal): move composables, types, schemas; drop duplicates
Composables (apps/portal/src/composables → apps/app/src/composables/):
  - useFormDraft, publicFormInjection → composables/ (root, used by
    shared/public-form components)
  - api/usePublicForm, api/usePublicFormSections,
    api/usePublicFormTimeSlots → composables/api/ (no collisions)
  - api/usePortalShifts, api/usePortalProfile, api/useVolunteerRegistration
    → composables/api/portal/ (subfolder per WS-3 PR-B1 charter to
    leave room for organizer-side namesakes without clashes)
  - api/useMfa → DELETED (apps/app version is a strict superset
    with extra invalidateQueries calls and the admin-reset mutation)

Types (apps/portal/src/types → apps/app/src/types/):
  - api, portal-shift, portal, registration → moved
  - mfa → DELETED (byte-identical to apps/app/src/types/mfa.ts)

Schemas:
  - apps/portal/src/schemas/registrationSchema.ts → apps/app/src/schemas/

Utils:
  - deviceFingerprint, paginationMeta → DELETED (byte-identical
    duplicates already in apps/app/src/utils/)

Lib:
  - apps/portal/src/lib/{axios,query-client}.ts → DELETED. apps/app's
    callback-bound axios (post-PR-A) and query-client are the
    canonical versions. Portal pages currently importing
    `@/lib/axios#apiClient` resolve to apps/app's apiClient with no
    behavioral change for cookie-based requests.

Tests: 4 composable specs (useFormDraft x2, usePublicFormSections,
usePublicFormTimeSlots) moved into __tests__/ subdirs alongside
their composables.

@form-schema imports inside the moved files rewritten to
@/composables/forms/*.

Vitest now: 23 files / 162 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:08:53 +02:00
4fe1a0c517 refactor(portal): move stores and rename portal auth store
- apps/portal/src/stores/useAuthStore.ts →
  apps/app/src/stores/portal/usePortalAuthStore.ts. The export and
  defineStore id are renamed (useAuthStore → usePortalAuthStore,
  'auth' → 'portalAuth') so it can coexist with the organizer's
  apps/app/src/stores/useAuthStore. Lazy import inside
  resetPortalStoresSync() updated to the new path.
- apps/portal/src/stores/usePortalStore.ts →
  apps/app/src/stores/portal/usePortalStore.ts (no name change —
  apps/app does not have a usePortalStore).

All call sites in moved pages/components now import from
@/stores/portal/{usePortalStore,usePortalAuthStore} and call
usePortalAuthStore() instead of useAuthStore().

PR-B2 will merge this back into a single context-aware auth store.

Also includes the C.1 page meta-block updates (layout: 'PortalLayout'
| 'PublicLayout', context: 'portal') that were left unstaged after
the page-rename commit picked up only the path change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:06:08 +02:00
98ec51fcbd refactor(portal): move components to shared/public-form and portal/{event,*}
- public-form/** (18 files + 7 component tests) → shared/public-form/**
  This is the runtime form-renderer; goes into shared/ because it will
  be reused by the organizer-app Form Builder preview (S3b).
- event/{Claimen,Informatie,Overzicht,Rooster}Tab.vue → portal/event/**
- portal/{StatusCard,EventCard,UserAvatarMenu}.vue → portal/** (no
  path change — both apps had a portal/ subfolder).
- AppLoadingIndicator.vue, auth/{PasswordRequirements,MfaChallengeCard}.vue,
  settings/Mfa{Disable,Email,Totp}SetupDialog.vue: portal copies
  deleted as duplicates of pre-existing apps/app components (diffs
  were trivial formatting only).

Inside the moved files: rewrote @form-schema/* → @/composables/forms/*
and @/components/{public-form,event/[Tab]} → new sub-zone paths.

Updated apps/app/tsconfig.json to drop the @form-schema path alias
and the packages/form-schema include path. Updated formSchema.ts to
import from @/composables/forms/types/formBuilder. Carried the
crypto polyfill from apps/portal/tests/setup.ts into
apps/app/tests/setup.ts (needed by useFormDraft tests landing in C.4).

NOTE: Some moved tests still fail because they reference portal
composables (usePublicFormSections, useFormDraft) that move in C.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:04:49 +02:00
4cfcd5306a refactor(portal): move pages from apps/portal to apps/app
Per WS-3 PR-B1 charter §4.2: portal pages relocate into the
single-SPA layout under apps/app/src/pages/portal/** (authenticated
portal context) and apps/app/src/pages/register/** (public
token-based form-fill / confirmation).

Updated meta blocks:
  - Portal pages: layout: 'PortalLayout', context: 'portal'
    (preserving original requiresAuth + nav fields)
  - Register pages: layout: 'PublicLayout' (drop requiresAuth)

Skipped (apps/portal duplicates of pages already in apps/app):
  index.vue, login.vue, wachtwoord-{vergeten,resetten}.vue,
  verify-email-change.vue. Deleted: [...path].vue (apps/app already
  has [...error].vue catch-all).

NOTE: Component/store/composable imports inside these files still
point at apps/portal-relative paths and will be rewritten in the
next commits. Build will not be green again until commit 6
(composables/lib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:58:06 +02:00
79954aace6 refactor(forms): move packages/form-schema → apps/app/src/composables/forms
Inlines the form-schema source folder (no package.json, alias-only)
into apps/app/src/composables/forms. Drops the @form-schema alias
from apps/app/vite.config.ts (replaced by @/composables/forms via
the existing @ alias). apps/portal vite + vitest configs keep
@form-schema as a temporary alias pointing at the new location so
portal tests/build keep working until apps/portal is removed at the
end of this PR. Two pure-logic form-schema tests moved alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:50:52 +02:00
966ded3e44 chore(monorepo): scaffold target sub-folders for WS-3 PR-B1
Creates portal/register/shared/forms sub-folders ahead of the moves
in subsequent commits. Empty .gitkeep markers will be replaced by
real content as the moves land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:44:24 +02:00
4a84b9e6f9 Merge: WS-6 closure documentation 2026-05-04 23:58:57 +02:00
deb75ee500 docs(backlog): add TECH-FORM-BUILDER-INTEGRATION-TEST-NAME-COVERAGE
Records the naming-vs-coverage gap surfaced during WS-6 closure
verification: ARCH-FORM-BUILDER §31 references five integration
contract tests by name that don't exist under those filenames in
api/tests/Feature/FormBuilder/Integration/. Coverage may be intact
under different filenames; only the §31 naming index is stale.

Low priority — defer to whoever next touches FormBuilder
integration tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:52:44 +02:00
d709da7858 docs(ws-6): record completion and verification
WS-6 (FormBindingApplicator pipeline) is fully landed in main —
sessions 1, 2, and 3 all merged. Verification on 2026-05-04
confirmed every RFC-WS-6.md §7 deliverable plus the v1.1/v1.2
addenda. Backend test suite green at 1486 tests, above the RFC
§8 target of 1445-1465.

Adds a closure-marker note documenting what's verified in main
and adds a single status line under §6.2 of the consolidation
plan pointing at it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:52:37 +02:00
877 changed files with 44355 additions and 61407 deletions

View File

@@ -11,3 +11,12 @@ dev-docs/BACKLOG.md
dev-docs/SECURITY_AUDIT.md
dev-docs/design-document.md
dev-docs/UX_SPEC_FESTIVAL_HIERARCHY.md
dev-docs/ARCH-BINDINGS.md
dev-docs/ARCH-API-VALIDATION.md
dev-docs/RFC-WS-7-OBSERVABILITY.md
dev-docs/GLITCHTIP.md
dev-docs/ARCH-OBSERVABILITY.md
dev-docs/runbooks/observability-triage.md
dev-docs/runbooks/observability-erasure.md
dev-docs/RFC-WS-6.md
dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md

2
.claude/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
worktrees/
.DS_Store

View File

@@ -0,0 +1,77 @@
---
name: crewli-reviewer
description: Reviews code against Crewli's zero-compromise principles. Use after any backend or frontend implementation is complete, before committing. Returns a structured MUST FIX / SHOULD FIX / CONSIDER report.
tools: Read, Grep, Glob, Bash
model: claude-opus-4-7
---
You are a staff engineer reviewing code for Crewli — a multi-tenant Laravel 12 + Vue 3 SaaS platform with zero tolerance for technical debt.
Read /CLAUDE.md and the relevant /dev-docs/SCHEMA.md sections before reviewing. Do not patch code — produce a structured report only.
## Review checklist (in order)
### Multi-tenancy (highest priority — leaks cross-org data)
- Every business model has `OrganisationScope` registered in `booted()` (NOT just imported).
- Every business table FK chain reaches `organisations.id` within ≤2 hops.
- Every model has a Policy class registered in `AuthServiceProvider`.
- Public/unscoped query paths explicitly bypass the scope (`->withoutGlobalScope(OrganisationScope::class)`).
- Pest tests include a cross-org leak assertion: a record from org B must not appear when authenticated as org A.
### Schema & types
- Primary keys: ULID on business tables, integer auto-increment on pure pivots — NEVER UUID v4.
- Status/type/category fields use a PHP Enum cast — NEVER string literals.
- Soft delete decision matches /dev-docs/SCHEMA.md — immutable audit records (check_ins, form_submissions) MUST NOT have softDeletes.
- JSON columns only for opaque config — never queryable data.
- Migration follows Laravel 12 anonymous-class style and uses `ulid('id')->primary()`.
### Service & controller layer
- Business logic lives in `app/Services/<Domain>Service.php` — NEVER in the controller.
- Controllers are thin: validate (FormRequest), delegate (Service), respond (Resource).
- Form Request validation rules cover every field, including enum cases.
- API Resource shapes the response — no raw `$model->toArray()`.
### Activity log
- Every state-changing model uses the `LogsActivity` trait from spatie/laravel-activitylog.
- `getActivitylogOptions()` configured (logName, logFillable or logOnly, logOnlyDirty).
- Suppress logging in seeders/factories via `ActivityLog::suppressed()` where appropriate.
### Queued jobs
- Jobs implement `ShouldQueue` and are idempotent.
- Side effects gated by a check (status flag, transient lock) so a re-run is safe.
- `tries`, `backoff`, `failed()` defined where retry semantics matter.
### Cleanup
- Old code that the new feature replaces is DELETED, not adapted. No dead code paths, no duplicate implementations.
### Frontend (apps/app or apps/portal)
- Component first checked against /dev-docs/VUEXY_COMPONENTS.md — Vuexy/Vuetify component used over hand-rolled HTML.
- Pinia store for shared state, TanStack Vue Query for server state — never raw axios in components.
- Forms use VeeValidate + Zod; error/empty/loading states explicitly rendered.
- TypeScript: no `any`. Strict types from API Resource shape.
## The six most-missed gaps (always check explicitly)
1. Business logic in controller instead of Service class.
2. String literals instead of PHP Enums.
3. Missing activity log on state-changing models.
4. Queued jobs not idempotent.
5. Replaced code not deleted (delete > adapt).
6. Frontend missing error/empty/loading states.
## Output format
Produce a single Markdown report with three sections:
### MUST FIX (blocking — violates a zero-compromise principle)
- Bullet list. For each: `path/to/file.php:LINE` — issue — required change.
### SHOULD FIX (non-blocking but clear improvement)
- Bullet list, same format.
### CONSIDER (judgment call — flagged for Bert)
- Bullet list, same format.
If the diff is clean: output `No issues found against the zero-compromise principles.` and stop.
Always cite `file:line`. No vague feedback. No prose padding.

View File

@@ -0,0 +1,26 @@
---
description: Check a model + migration + policy + tests for multi-tenancy correctness
argument-hint: [model-name]
allowed-tools: Read, Grep, Glob
---
Target: $ARGUMENTS
Locate and read all four artefacts:
1. The Eloquent model — `app/Models/$ARGUMENTS.php` (or matching path under `app/Models/`).
2. The most recent migration that creates or modifies the table.
3. The policy class — `app/Policies/${ARGUMENTS}Policy.php`.
4. Pest tests for the model — search `tests/` for the class name.
Verify each item against /dev-docs/SCHEMA.md and /CLAUDE.md, marking PASS / FAIL / N/A with one-line reasoning:
- [ ] Primary key is ULID (`ulid('id')->primary()` in migration; `HasUlids` trait on model)
- [ ] `OrganisationScope` registered in model `booted()` (NOT merely imported)
- [ ] FK chain reaches `organisations.id` within ≤2 hops
- [ ] Policy class exists and is registered in `AuthServiceProvider::$policies`
- [ ] All status/type/category columns use a PHP Enum cast
- [ ] Soft delete decision matches the type's row in /dev-docs/SCHEMA.md
- [ ] Pest tests include a cross-org leak assertion
- [ ] Activity log trait present (if state-changing)
End with a verdict line: `READY` or `NEEDS WORK`.

View File

@@ -0,0 +1,15 @@
---
description: Summarise current sprint position from BACKLOG.md, recent commits, and working tree
allowed-tools: Bash(git:*), Read
---
Read the first 50 lines of `dev-docs/BACKLOG.md`.
Run `git branch --show-current`, `git log --oneline -20`, and `git status -sb`.
Produce a 510 line summary covering:
- Current branch and the work package it belongs to
- Last completed work package (most recent --no-ff merge in the log)
- Staged or unstaged work (uncommitted changes)
- Next item per BACKLOG.md
No prose padding. Just the facts.

View File

@@ -0,0 +1,11 @@
---
description: Run the dev-docs sync pipeline and remind to upload .claude-sync/
allowed-tools: Bash(npm:*), Read
---
Run `npm run sync:docs`.
After it completes, read `.claude-sync/SYNC_MANIFEST.md` and print the `Git SHA` and `Generated` lines.
End with this exact warning block:
> ⚠️ Manual step required: upload `.claude-sync/` (including `SYNC_MANIFEST.md`) to Project Knowledge in Claude Chat. The drift-check protocol depends on this. Without upload the sync is stale and Claude Chat will work from outdated dev-docs.

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
input="$(cat)"
cmd="$(echo "$input" | jq -r '.tool_input.command // empty')"
[ -z "$cmd" ] && exit 0
block() {
echo "Bash command blocked: $1. $2." >&2
exit 2
}
# git reset --hard
if echo "$cmd" | grep -Eq 'git[[:space:]]+reset[[:space:]]+--hard'; then
block "git reset --hard destroys local work" "Use 'git stash' to set work aside, or branch off the current state before resetting"
fi
# git push --force / -f
if echo "$cmd" | grep -Eq 'git[[:space:]]+push[[:space:]]+(--force([[:space:]]|=|$)|-f([[:space:]]|$))'; then
block "force push rewrites history" "Crewli uses --no-ff merges; never force-push. If the remote diverged, pull/rebase locally and resolve"
fi
# rm -rf on absolute paths outside /tmp and /home/<user>/
if echo "$cmd" | grep -Eq '\brm[[:space:]]+-rf?[[:space:]]+/' && ! echo "$cmd" | grep -Eq '\brm[[:space:]]+-rf?[[:space:]]+/(tmp|var/folders|home/[^/[:space:]]+/[^[:space:]]|Users/[^/[:space:]]+/[^[:space:]])'; then
block "rm -rf on an absolute path outside /tmp" "Verify the path is project-relative; if you really need it, run it manually outside Claude Code"
fi
# php artisan migrate:fresh — only with --env=testing
if echo "$cmd" | grep -Eq 'php[[:space:]]+artisan[[:space:]]+migrate:fresh\b'; then
if ! echo "$cmd" | grep -Eq -- '--env=testing\b'; then
block "migrate:fresh wipes the database" "Add --env=testing to scope this to the test database, or run a non-destructive 'migrate' / 'migrate:rollback'"
fi
fi
# php artisan db:wipe — only with --env=testing
if echo "$cmd" | grep -Eq 'php[[:space:]]+artisan[[:space:]]+db:wipe\b'; then
if ! echo "$cmd" | grep -Eq -- '--env=testing\b'; then
block "db:wipe destroys the database" "Add --env=testing to scope this to the test database"
fi
fi
# composer/pnpm/npm update
if echo "$cmd" | grep -Eq '\b(composer|pnpm|npm)[[:space:]]+update\b'; then
block "blanket dependency update bumps everything without review" "Use targeted 'composer require <pkg>' or 'pnpm add <pkg>' to bump one package at a time"
fi
exit 0

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0
branch="$(git branch --show-current 2>/dev/null || echo unknown)"
recent="$(git log --oneline -10 2>/dev/null || echo '(git log unavailable)')"
backlog_excerpt="(BACKLOG.md not found)"
if [ -f dev-docs/BACKLOG.md ]; then
# First 40 lines, capped at ~3500 chars to stay well under 600 tokens for the whole output.
backlog_excerpt="$(head -n 40 dev-docs/BACKLOG.md | head -c 3500)"
fi
cat <<EOF
## Sprint context (re-injected after compaction)
**Branch:** $branch
**Last 10 commits:**
$recent
**Top of BACKLOG.md:**
$backlog_excerpt
Reminder: re-read /CLAUDE.md if any zero-compromise principle is unclear. /dev-docs/SCHEMA.md is authoritative for table structure.
EOF
exit 0

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
input="$(cat)"
path="$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')"
[ -z "$path" ] && exit 0
# Strip leading absolute prefix if present, so we match repo-relative paths.
rel="$path"
if [[ "$path" = /* ]]; then
rel="${path#$CLAUDE_PROJECT_DIR/}"
fi
# Match apps/app/** for .vue/.ts/.tsx/.js files.
if ! echo "$rel" | grep -Eq '^apps/app/.+\.(vue|ts|tsx|js)$'; then
exit 0
fi
# Path inside apps/app/.
inside="${rel#apps/app/}"
cd "$CLAUDE_PROJECT_DIR/apps/app" 2>/dev/null || exit 0
pnpm eslint --fix "$inside" >/dev/null 2>&1 || true
exit 0

17
.claude/hooks/post-edit-pint.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
input="$(cat)"
path="$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')"
# No path or non-PHP — nothing to do.
[ -z "$path" ] && exit 0
[[ "$path" != *.php ]] && exit 0
# Laravel app lives in api/. Pint binary is at api/vendor/bin/pint.
cd "$CLAUDE_PROJECT_DIR/api" 2>/dev/null || exit 0
[ -x vendor/bin/pint ] || exit 0
# --dirty only formats files with git changes — fast even when called per-edit.
vendor/bin/pint --dirty >/dev/null 2>&1 || true
exit 0

49
.claude/hooks/protect-files.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
input="$(cat)"
path="$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')"
[ -z "$path" ] && exit 0
block() {
echo "Edit to '$path' blocked: $1. $2." >&2
exit 2
}
# .env files (but not .env.example)
if echo "$path" | grep -Eq '(^|/)\.env(\..*)?$' && ! echo "$path" | grep -Eq '(^|/)\.env\.example$'; then
block "secrets" "Propose changes to .env.example instead"
fi
# composer.lock
if echo "$path" | grep -Eq '(^|/)composer\.lock$'; then
block "locked dependency tree" "Run composer require deliberately, then commit the regenerated lock file"
fi
# JS lock files
if echo "$path" | grep -Eq '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$'; then
block "locked JS dependency tree" "Run pnpm add / npm install deliberately, then commit the regenerated lock file"
fi
# Laravel default migrations
if echo "$path" | grep -Eq '(^|/)database/migrations/0001_01_01_.*\.php$'; then
block "Laravel default migration" "Never modify Laravel scaffold migrations — write a new migration that alters the table"
fi
# apps/admin/ — deleted SPA per WS-3
if echo "$path" | grep -Eq '(^|/)apps/admin/'; then
block "apps/admin/ was deleted in WS-3 and must not return" "Use apps/app/ (Organizer SPA, includes Platform Admin under /platform/*)"
fi
# .claude/ tooling self-modification
if echo "$path" | grep -Eq '(^|/)\.claude/'; then
block "tooling self-modification — Bert reviews .claude/ changes by hand" "Open the file in an editor outside Claude Code, or ask Bert to authorize the change explicitly"
fi
# dev-docs/SCHEMA.md
if echo "$path" | grep -Eq '(^|/)dev-docs/SCHEMA\.md$'; then
block "SCHEMA.md is updated only at sprint milestones" "Bert decides when SCHEMA snapshots roll forward — do not edit ad hoc"
fi
exit 0

50
.claude/settings.json Normal file
View File

@@ -0,0 +1,50 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous-bash.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-pint.sh"
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-eslint.sh"
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-sprint-context.sh"
}
]
}
]
}
}

View File

@@ -1,436 +0,0 @@
# Crewli - Architecture
> Multi-tenant SaaS platform for event- and festival management.
> Source of truth: `/resources/design/design-document.md`
## System Overview
```
┌─────────────────────────────────────────────────────────────────────────┐
│ INTERNET │
└─────────────────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Organizer + │ │ Portal SPA │
│ Admin SPA │ │ (External) │
│ :5174 │ │ :5175 │
└───────┬───────┘ └───────┬───────┘
│ │
└───────────────┼───────────────┘
│ CORS + Sanctum tokens
┌───────────────────────┐
│ Laravel 12 REST API │
│ (JSON only, no │
│ Blade views) │
│ :8000 │
└───────────┬───────────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ MySQL 8 │ │ Redis │ │ Mailpit │
│ :3306 │ │ :6379 │ │ :8025 │
└───────────┘ └───────────┘ └───────────┘
```
**Golden Rule:** Laravel is exclusively a JSON REST API. No Blade views, no Mix, no Inertia. Every response is `application/json`. Vue handles ALL UI via two SPAs.
---
## Applications
### Organizer App (`apps/app/`)
**Purpose**: Main application for event management per organisation. Also serves as the platform admin interface for `super_admin` users via `/platform/*` routes.
**Users**: Organisation Admins, Event Managers, Staff Coordinators, Artist Managers, Volunteer Coordinators, Super Admins (platform management via `/platform/*`).
**Features**:
- Event lifecycle management (Draft through Closed)
- Festival Sections, Time Slots, Shift planning
- Person & Crowd management (Crew, Volunteers, Artists, Guests, Press, Partners, Suppliers)
- Accreditation engine (categories, items, access zones)
- Artist booking & advancing
- Timetable & stage management
- Briefing builder & communication hub
- Mission Control (show day operations)
- Form builder with conditional logic
- Supplier & production management
- Reporting & insights
- Platform admin: organisation management, billing, platform users (`/platform/*` routes, `super_admin` only)
**Vuexy Version**: `typescript-version/full-version` (customized navigation)
---
### Portal (`apps/portal/`)
**Purpose**: External-facing portal with two access modes.
**Users**: Volunteers, Crew (login-based), Artists, Suppliers, Press (token-based).
**Access Modes**:
| User | Access Mode | Rationale |
|------|-------------|-----------|
| Volunteer / Crew | Login (`auth:sanctum`) | Long-term relationship, festival passport, shift history |
| Artist / Tour Manager | Token (`portal.token` middleware) | Per-event, advance portal via signed URL |
| Supplier / Partner | Token (`portal.token` middleware) | Per-event, production request via token |
| Press / Media | Token (`portal.token` middleware) | Per-event accreditation, no recurring relationship |
**Router guard logic**: If `route.query.token` -> token mode. If `authStore.isAuthenticated` -> login mode. Otherwise -> redirect to `/login`.
**Vuexy Version**: `typescript-version/starter-kit` (stripped: no sidebar, no customizer, no dark mode toggle; uses Vuetify components + Vuexy SCSS)
---
## Multi-Tenant Data Model
Shared database schema with organisation scoping on all tables. No row-level security at DB level; scoping enforced via Laravel Policies and Eloquent Global Scopes.
**Scoping Rule**: EVERY query on event data MUST have an `organisation_id` scope via `OrganisationScope` Global Scope.
**Tenancy Hierarchy**:
```
Platform (Super Admin)
└─ Organisation (client A)
└─ Event (event 1)
└─ Festival Section (Bar, Hospitality, Technical, ...)
├─ Time Slots (DAY1-EARLY-CREW, DAY1-EARLY-VOLUNTEER, ...)
└─ Shifts (Bar x DAY1-EARLY-VOLUNTEER, 5 slots)
```
---
## Three-Level Role & Permission Model
Managed via Spatie `laravel-permission` with team-based permissions.
| Level | Scope | Roles | Implementation |
|-------|-------|-------|----------------|
| App Level | Whole platform | `super_admin`, `support_agent` | Spatie role |
| Organisation Level | Within one org | `org_admin`, `org_member`, `org_readonly` | Spatie team = organisation |
| Event Level | Within one event | `event_manager`, `artist_manager`, `staff_coordinator`, `volunteer_coordinator`, `accreditation_officer` | `event_user_roles` pivot table |
**Middleware**: `OrganisationRoleMiddleware` and `EventRoleMiddleware` check per route.
---
## Event Lifecycle
| Phase | Description |
|-------|-------------|
| `draft` | Created but not published. Only admin sees it. |
| `published` | Active in planning. Internal modules available. External portals closed. |
| `registration_open` | Volunteer registration and artist advance portals open. |
| `buildup` | Setup days. Crew shifts begin. Accreditation distribution starts. |
| `showday` | Active event days. Mission Control active. Real-time check-in. |
| `teardown` | Breakdown days. Inventory return. Shift closure. |
| `closed` | Event completed. Read-only. Reports available. |
---
## API Structure
### Base URL
- Development: `http://localhost:8000/api/v1`
### Route Groups
```
# Public (no auth)
POST /auth/login
POST /portal/token-auth Token-based portal access
POST /portal/form-submit Public form submission
# Protected (auth:sanctum)
POST /auth/logout
GET /auth/me Returns user + organisations + event roles
# Organisations
GET/POST /organisations
GET/PUT /organisations/{id}
POST /organisations/{id}/invite
GET /organisations/{id}/members
# Events (nested under organisations)
GET/POST /organisations/{org}/events
GET/PUT/DELETE /events/{id}
PUT /events/{id}/status
# Festival Sections
GET/POST /events/{event}/sections
GET/PUT/DELETE /sections/{id}
GET /sections/{id}/dashboard
# Time Slots
GET/POST /events/{event}/time-slots
PUT/DELETE /time-slots/{id}
# Shifts
GET/POST /sections/{section}/shifts
PUT/DELETE /shifts/{id}
POST /shifts/{id}/assign
POST /shifts/{id}/claim Volunteer self-service
# Persons
GET/POST /events/{event}/persons
GET/PUT /persons/{id}
POST /persons/{id}/approve
POST /persons/{id}/checkin
# Crowd Types & Lists
GET/POST /organisations/{org}/crowd-types
GET/POST /events/{event}/crowd-lists
# Artists & Advancing
GET/POST /events/{event}/artists
GET/PUT /artists/{id}
GET/POST /artists/{id}/sections Advance sections
POST /sections/{id}/submit Advance submission
# Accreditation
GET/POST /events/{event}/accreditation-items
POST /persons/{id}/accreditations
GET/POST /events/{event}/access-zones
# Briefings & Communication
GET/POST /events/{event}/briefings
POST /briefings/{id}/send
GET/POST /events/{event}/campaigns
POST /campaigns/{id}/send
# Mission Control
GET /events/{event}/mission-control
POST /persons/{id}/checkin-item
# Scanners & Inventory
GET/POST /events/{event}/scanners
POST /scan
GET/POST /events/{event}/inventory
# Reports
GET /events/{event}/reports/{type}
# Portal (token-based, portal.token middleware)
GET /portal/artist
POST /portal/advancing
GET /portal/supplier
POST /portal/production-request
```
### API Response Format
```json
{
"data": { ... },
"meta": {
"pagination": {
"current_page": 1,
"per_page": 15,
"total": 100,
"last_page": 7
}
}
}
```
---
## Core Database Schema
**Primary Keys**: ULID on all business tables via `HasUlids` trait. Pure pivot tables use auto-increment integer PK.
**Soft Deletes ON**: organisations, events, festival_sections, shifts, shift_assignments, persons, artists, companies, production_requests.
**Soft Deletes OFF** (audit records): check_ins, briefing_sends, message_replies, shift_waitlist, volunteer_festival_history.
**JSON Columns**: ONLY for opaque config (blocks, fields, settings, items). NEVER for dates, status values, foreign keys, booleans, or anything filtered/sorted/aggregated.
### 1. Foundation
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `users` | id (ULID), name, email, password, timezone, locale, avatar, deleted_at | Platform-wide, unique per email. |
| `organisations` | id (ULID), name, slug, billing_status, settings (JSON: display prefs only), deleted_at | hasMany events, crowd_types. |
| `organisation_user` | id (int AI), user_id, organisation_id, role | Pivot. Integer PK. |
| `user_invitations` | id (ULID), email, invited_by_user_id, organisation_id, event_id (nullable), role, token (ULID unique), status, expires_at | INDEX: (token), (email, status). |
| `events` | id (ULID), organisation_id, name, slug, start_date, end_date, timezone, status (enum), deleted_at | INDEX: (organisation_id, status). |
| `event_user_roles` | id (int AI), user_id, event_id, role | Pivot. Integer PK. |
### 2. Locations
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `locations` | id (ULID), event_id, name, address, lat, lng, description, access_instructions | INDEX: (event_id). |
### 3. Festival Sections, Time Slots & Shifts
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `festival_sections` | id (ULID), event_id, name, sort_order, deleted_at | INDEX: (event_id, sort_order). |
| `time_slots` | id (ULID), event_id, name, person_type (CREW/VOLUNTEER/PRESS/...), date, start_time, end_time | INDEX: (event_id, person_type, date). |
| `shifts` | id (ULID), festival_section_id, time_slot_id, location_id, slots_total, slots_open_for_claiming, status, deleted_at | INDEX: (festival_section_id, time_slot_id). |
| `shift_assignments` | id (ULID), shift_id, person_id, time_slot_id (denormalized), status (pending_approval/approved/rejected/cancelled/completed), auto_approved, deleted_at | UNIQUE(person_id, time_slot_id). |
| `volunteer_availabilities` | id (ULID), person_id, time_slot_id, submitted_at | UNIQUE(person_id, time_slot_id). |
| `shift_waitlist` | id (ULID), shift_id, person_id, position, added_at | UNIQUE(shift_id, person_id). |
| `shift_swap_requests` | id (ULID), from_assignment_id, to_person_id, status, auto_approved | |
| `shift_absences` | id (ULID), shift_assignment_id, person_id, reason, status | |
### 4. Volunteer Profile & History
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `volunteer_profiles` | id (ULID), user_id (unique), bio, tshirt_size, first_aid, driving_licence, reliability_score (0.00-5.00) | Platform-wide, 1:1 with users. |
| `volunteer_festival_history` | id (ULID), user_id, event_id, hours_planned, hours_completed, no_show_count, coordinator_rating, would_reinvite | UNIQUE(user_id, event_id). Never visible to volunteer. |
| `post_festival_evaluations` | id (ULID), event_id, person_id, overall_rating, would_return, feedback_text | |
| `festival_retrospectives` | id (ULID), event_id (unique), KPI columns, top_feedback (JSON) | |
### 5. Crowd Types, Persons & Crowd Lists
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `crowd_types` | id (ULID), organisation_id, name, system_type (CREW/GUEST/ARTIST/VOLUNTEER/PRESS/PARTNER/SUPPLIER), color, icon | Org-level config. |
| `persons` | id (ULID), user_id (nullable), event_id, crowd_type_id, company_id (nullable), name, email, phone, status, is_blacklisted, custom_fields (JSON), deleted_at | user_id nullable for externals. UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL. |
| `companies` | id (ULID), organisation_id, name, type, contact_*, deleted_at | Shared across events within org. |
| `crowd_lists` | id (ULID), event_id, crowd_type_id, name, type (internal/external), auto_approve, max_persons | |
| `crowd_list_persons` | id (int AI), crowd_list_id, person_id | Pivot. |
### 6. Accreditation Engine
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `accreditation_categories` | id (ULID), organisation_id, name, sort_order, icon | Org-level. |
| `accreditation_items` | id (ULID), accreditation_category_id, name, is_date_dependent, barcode_type, cost_price | Org-level items. |
| `event_accreditation_items` | id (ULID), event_id, accreditation_item_id, max_quantity_per_person, is_active | Activates item per event. UNIQUE(event_id, accreditation_item_id). |
| `accreditation_assignments` | id (ULID), person_id, accreditation_item_id, event_id, date, quantity, is_handed_out | |
| `access_zones` | id (ULID), event_id, name, zone_code (unique per event) | |
| `access_zone_days` | id (int AI), access_zone_id, day_date | UNIQUE(access_zone_id, day_date). |
| `person_access_zones` | id (int AI), person_id, access_zone_id, valid_from, valid_to | |
### 7. Artists & Advancing
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `artists` | id (ULID), event_id, name, booking_status (concept/requested/option/confirmed/contracted/cancelled), portal_token (ULID unique), deleted_at | |
| `performances` | id (ULID), artist_id, stage_id, date, start_time, end_time, check_in_status | INDEX: (stage_id, date, start_time). |
| `stages` | id (ULID), event_id, name, color, capacity | |
| `stage_days` | id (int AI), stage_id, day_date | UNIQUE(stage_id, day_date). |
| `advance_sections` | id (ULID), artist_id, name, type, is_open, sort_order | |
| `advance_submissions` | id (ULID), advance_section_id, data (JSON), status | |
| `artist_contacts` | id (ULID), artist_id, name, email, role | |
| `artist_riders` | id (ULID), artist_id, category (technical/hospitality), items (JSON) | |
### 8. Communication & Briefings
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `briefing_templates` | id (ULID), event_id, name, type, blocks (JSON) | |
| `briefings` | id (ULID), event_id, briefing_template_id, name, target_crowd_types (JSON), status | |
| `briefing_sends` | id (ULID), briefing_id, person_id, status (queued/sent/opened/downloaded) | NO soft delete. |
| `communication_campaigns` | id (ULID), event_id, type (email/sms/whatsapp), status | |
| `messages` | id (ULID), event_id, sender_user_id, recipient_person_id, urgency (normal/urgent/emergency) | |
| `broadcast_messages` | id (ULID), event_id, sender_user_id, body, urgency | |
### 9. Forms, Check-In & Operational
| Table | Key Columns | Notes |
|-------|-------------|-------|
| `public_forms` | id (ULID), event_id, crowd_type_id, fields (JSON), conditional_logic (JSON), iframe_token | |
| `form_submissions` | id (ULID), public_form_id, person_id, data (JSON) | |
| `check_ins` | id (ULID), event_id, person_id, scanned_by_user_id, scanned_at | NO soft delete. Immutable audit record. |
| `scanners` | id (ULID), event_id, name, type, pairing_code | |
| `inventory_items` | id (ULID), event_id, name, item_code, assigned_to_person_id | |
| `production_requests` | id (ULID), event_id, company_id, title, status, token (ULID unique) | |
| `material_requests` | id (ULID), production_request_id, category, name, quantity, status | |
---
## Model Relationships
**User**
- belongsToMany Organisations (via `organisation_user`)
- belongsToMany Events (via `event_user_roles`)
**Organisation**
- hasMany Events
- hasMany CrowdTypes
- hasMany AccreditationCategories
- hasMany Companies
- belongsToMany Users (via `organisation_user`)
**Event**
- belongsTo Organisation
- hasMany FestivalSections
- hasMany TimeSlots
- hasMany Persons
- hasMany Artists
- hasMany Briefings
- hasMany Locations
- hasMany AccessZones
- hasMany PublicForms
**FestivalSection**
- belongsTo Event
- hasMany Shifts
**TimeSlot**
- belongsTo Event
- hasMany Shifts
- hasMany ShiftAssignments (denormalized)
**Shift**
- belongsTo FestivalSection
- belongsTo TimeSlot
- belongsTo Location (nullable)
- hasMany ShiftAssignments
**Person**
- belongsTo Event
- belongsTo CrowdType
- belongsTo User (nullable)
- belongsTo Company (nullable)
- hasMany ShiftAssignments
- hasMany AccreditationAssignments
- hasMany CheckIns
**Artist**
- belongsTo Event
- hasMany Performances
- hasMany AdvanceSections
- hasMany ArtistContacts
---
## Security & CORS
Two frontend origins in `config/cors.php` (via env):
| App | Dev URL | Env Variable |
|-----|---------|--------------|
| App | `http://localhost:5174` | `FRONTEND_APP_URL` |
| Portal | `http://localhost:5175` | `FRONTEND_PORTAL_URL` |
Production (registered domain **crewli.app**): API `https://api.crewli.app` (`APP_URL`); SPAs `https://crewli.app`, `https://portal.crewli.app` via the same env keys. Frontends use `VITE_API_URL=https://api.crewli.app/api/v1`. `SANCTUM_STATEFUL_DOMAINS` = comma-separated SPA hostnames only (e.g. `crewli.app,portal.crewli.app`). **`crewli.nl`** is reserved for a future marketing site only — not used for this application stack.
---
## Real-time Events (WebSocket)
Via Laravel Echo + Pusher/Soketi:
- `PersonCheckedIn`
- `ShiftFillRateChanged`
- `ArtistCheckInStatusChanged`
- `AdvanceSectionSubmitted`
- `AccreditationItemHandedOut`
- `BriefingSendQueued`
---
*Source: Crewli Design Document v1.3, March 2026*

View File

@@ -1,222 +0,0 @@
# Crewli - Cursor AI Instructions
> Multi-tenant SaaS platform for event- and festival management.
> Design Document: `/resources/design/design-document.md`
> Dev Guide: `/resources/design/dev-guide.md`
> Start Guide: `/resources/design/start-guide.md`
## Project Overview
**Name**: Crewli
**Type**: Multi-tenant SaaS platform (API-first architecture)
**Status**: Development
### Description
Crewli is a multi-tenant SaaS platform for professional event and festival management. It supports the full operational cycle: artist booking and advancing, staff planning and volunteer management, accreditation, briefings, and real-time show-day operations (Mission Control). Built for a professional volunteer organisation, with SaaS expansion potential.
## Quick Reference
| Component | Technology | Location | Port |
|-----------|------------|----------|------|
| API | Laravel 12 + Sanctum + Spatie Permission | `api/` | 8000 |
| Organizer + Admin App (Main) | Vue 3 + Vuexy (full) | `apps/app/` | 5174 |
| Portal (External) | Vue 3 + Vuexy (stripped) | `apps/portal/` | 5175 |
| Database | MySQL 8 | Docker | 3306 |
| Cache / Queues | Redis | Docker | 6379 |
| Mail | Mailpit | Docker | 8025 |
## Documentation Structure
```
.cursor/
├── instructions.md # This file - overview and quick start
├── ARCHITECTURE.md # System architecture, schema, API routes
└── rules/
├── 001_workspace.mdc # Project structure, conventions, multi-tenancy
├── 100_laravel.mdc # Laravel API patterns and templates
├── 101_vue.mdc # Vue + Vuexy patterns and templates
└── 200_testing.mdc # Testing strategies and templates
```
---
## Core Modules
### Phase 1 - Foundation
- [ ] Multi-tenant architecture + Auth (Sanctum + Spatie)
- [ ] Users, Roles & Permissions (three-level model)
- [ ] Organisations CRUD + User Invitations
- [ ] Events CRUD with lifecycle status
- [ ] Crowd Types (org-level configuration)
- [ ] Festival Sections + Time Slots + Shifts
- [ ] Persons & Crowd Lists
- [ ] Accreditation Engine (categories, items, access zones)
### Phase 2 - Core Operations
- [ ] Briefings & Communication (template builder, queue-based sending)
- [ ] Staff & Crew Management (crowd pool, accreditation matrix)
- [ ] Volunteer Management + Portal (registration, shift claiming, approval flow)
- [ ] Form Builder (drag-drop, conditional logic, iframe embed)
- [ ] Artist Advancing + Portal (token-based access)
- [ ] Timetable & Stage management
- [ ] Show Day Mode
- [ ] Shift Swap & Waitlist
- [ ] Volunteer Profile + Festival Passport
- [ ] Communication Hub (email/SMS/WhatsApp via Zender, urgency levels)
### Phase 3 - Advancing & Show Day
- [ ] Guests & Hospitality
- [ ] Suppliers & Production (production requests, supplier portal)
- [ ] Mission Control (real-time check-in, artist handling, scanner management)
- [ ] Communication Campaigns (email + SMS batch)
- [ ] Allocation Sheet PDF (Browsershot)
- [ ] Scan infrastructure (hardware pairing)
- [ ] Reporting & Insights
- [ ] No-show automation
- [ ] Post-festival evaluation + retrospective
### Phase 4 - Differentiators
- [ ] Real-time WebSocket notifications (Echo + Pusher/Soketi)
- [ ] Cross-event crew pool with reliability score
- [ ] Global search (cmd+K)
- [ ] Crew PWA
- [ ] Public REST API + webhook system
- [ ] CO2/sustainability reporting
---
## Module Development Order (per module)
Always follow this sequence:
1. Migration(s) - ULID PKs, composite indexes, constrained FKs
2. Eloquent Model - HasUlids, relations, scopes, OrganisationScope
3. Factory - realistic Dutch test data
4. Policy - authorization via Spatie roles
5. Form Request(s) - Store + Update validation
6. API Resource - computed fields, `whenLoaded()`, permission-dependent fields
7. Resource Controller - index/show/store/update/destroy
8. Routes in `api.php`
9. PHPUnit Feature Test - happy path (200/201) + unauthenticated (401) + wrong organisation (403) + validation (422)
10. Vue Composable (`useModuleName.ts`) - TanStack Query
11. Pinia Store (if cross-component state needed)
12. Vue Page Component
13. Vue Router entry
---
## Getting Started Prompts
### 1. Phase 1 Foundation (Backend)
```
Read CLAUDE.md. Then generate Phase 1 Foundation:
1. Migrations: Update users (add timezone, locale, deleted_at). Create organisations (ULID, name, slug, billing_status, settings JSON, deleted_at), organisation_user pivot, user_invitations, events (ULID, organisation_id, name, slug, start_date, end_date, timezone, status enum, deleted_at), event_user_roles pivot.
2. Models: User (update), Organisation, UserInvitation, Event. All with HasUlids, SoftDeletes where applicable, OrganisationScope on Event.
3. Spatie Permission: RoleSeeder with roles: super_admin, org_admin, org_member, event_manager, staff_coordinator, volunteer_coordinator.
4. Auth: LoginController, LogoutController, MeController (returns user + organisations + active event roles).
5. Organisations: Controller, Policy, Request, Resource.
6. Events: Controller nested under organisations, Policy, Request, Resource.
7. Feature tests per step. Run php artisan test after each step.
```
### 2. Phase 1 Foundation (Frontend)
```
Build auth flow in apps/app/:
1. stores/useAuthStore.ts - token storage, isAuthenticated, me() loading
2. pages/login.vue - use Vuexy login layout
3. Router guard - redirect to login if not authenticated
4. Replace Vuexy demo navigation with Crewli structure
5. CASL permissions: connect to Spatie roles from auth/me response
```
### 3. Module Generation (example: Shifts)
```
Build the Shifts module following CLAUDE.md module order:
- Migration with ULID PK, festival_section_id, time_slot_id, location_id, slots_total, slots_open_for_claiming, status. Composite indexes.
- Model with HasUlids, SoftDeletes, relations, computed accessors (slots_filled, fill_rate).
- shift_assignments with denormalized time_slot_id, status machine (pending_approval > approved/rejected/cancelled/completed), UNIQUE(person_id, time_slot_id).
- Configurable auto-approve per shift.
- Queued notification jobs using ZenderService for WhatsApp.
- Feature tests covering 200/401/403/422.
```
---
## Common Tasks
### Add a New API Endpoint
1. Create/update Controller in `app/Http/Controllers/Api/V1/`
2. Create Form Request in `app/Http/Requests/Api/V1/`
3. Create/update API Resource in `app/Http/Resources/Api/V1/`
4. Add route in `routes/api.php`
5. Create Service class if complex business logic needed
6. Write PHPUnit Feature Test (200/401/403/422)
### Add a New Vue Page
1. Create page component in `src/pages/`
2. Route added automatically by file-based routing (or add to router)
3. Add navigation item in `src/navigation/`
4. Create composable for API calls in `src/composables/`
5. Use Vuexy/Vuetify components for UI
### Add a New Database Table
1. Create migration with ULID PK, composite indexes
2. Create model with HasUlids, relations, OrganisationScope (if applicable)
3. Create factory with realistic Dutch test data
4. Create Policy, Form Request, Resource, Controller
5. Register routes in `api.php`
6. Write PHPUnit Feature Test
---
## Code Generation Preferences
When generating code, always:
- Use PHP 8.2+ features (typed properties, enums, match, readonly)
- Use `declare(strict_types=1);`
- Use ULID primary keys via HasUlids trait
- Use Spatie laravel-permission for roles (never hardcode role strings)
- Scope all queries on `organisation_id` via Global Scope
- Use `<script setup lang="ts">` for Vue components
- Use TanStack Query for all API calls
- Use VeeValidate + Zod for form validation
- Use Vuetify/Vuexy components for UI (never custom CSS if Vuetify class exists)
---
## Environment Setup
### Docker Services
```bash
make services # Start MySQL, Redis, Mailpit
make services-stop # Stop services
```
### Development Servers
```bash
make api # Laravel on :8000
make app # Organizer + Admin SPA on :5174
make portal # Portal SPA on :5175
```
### Database
```bash
make migrate # Run migrations
make fresh # Fresh migrate + seed
```
### Testing
```bash
cd api && php artisan test # All tests
cd api && php artisan test --filter=ShiftTest # Specific test
cd api && php artisan test --coverage # With coverage
```

View File

@@ -1,6 +1,6 @@
---
description: Vue 3, TypeScript, and Vuexy patterns for Crewli platform
globs: ["apps/**/*.{vue,ts,tsx}"]
description: Vue 3, TypeScript, and Vuexy patterns for Crewli
globs: ["apps/app/**/*.{vue,ts,tsx}"]
alwaysApply: true
---
@@ -8,675 +8,78 @@ alwaysApply: true
## Core Principles
1. **Composition API only** - Always `<script setup lang="ts">`
2. **TypeScript strict mode** - No `any` types
3. **TanStack Query for API** - Never raw axios in components
4. **Pinia for client state** - Server data stays in TanStack Query
5. **Vuexy/Vuetify components** - Never custom CSS if a Vuetify class exists
6. **VeeValidate + Zod** - For all form validation
7. **Mobile-first** - Minimum 375px width
1. **Composition API only** — always `<script setup lang="ts">`, never Options API
2. **No `any` types** — use proper typing or `unknown` + narrowing
3. **TanStack Query for API** — never raw axios in components
4. **Pinia for cross-component client state** — server data lives in TanStack Query, never duplicated in stores
5. **Vuetify components first** — custom CSS only when no Vuetify class fits the use case
6. **VeeValidate + Zod** for all form validation
7. **Mobile-first** — minimum 375px width, responsive at every breakpoint
## App-Specific Rules
## File structure
### `apps/app/` (Organizer + Platform Admin - Main App)
- Sidebar nav customized for Crewli structure
- Remove Vuexy demo/customizer components
- Full Vuetify component usage
- 90% of development work happens here
- Super admin functionality under `/platform/*` routes for `super_admin` users
### `apps/portal/` (External Portal)
- Stripped Vuexy: no sidebar, no customizer, no dark mode toggle
- Custom layout: top-bar with event logo + name
- Uses Vuetify components + Vuexy SCSS variables
- Two access modes: login (volunteers) and token (artists/suppliers)
- Mobile-first design
## Vuexy Folder Rules
### Never Modify
```
src/@core/ # Vuexy core
src/@layouts/ # Vuexy layouts
apps/app/src/
├── lib/axios.ts # Single axios instance (do not duplicate)
├── composables/api/use*.ts # TanStack Query composables (one per resource)
├── stores/use*Store.ts # Pinia stores — client state only
├── types/*.ts # TypeScript interfaces (mirror backend Resources)
├── pages/ # File-based routing via unplugin-vue-router
├── layouts/ # Layout components
├── components/ # Reusable components
└── @core/ # Vuexy core — DO NOT MODIFY
```
### Customize
```
src/
├── components/ # Custom components
├── composables/ # useModule.ts composables (TanStack Query)
├── layouts/ # App layout customizations
├── lib/ # axios.ts (SINGLE axios instance per app)
├── navigation/ # Sidebar menu items
├── pages/ # Page components
├── plugins/ # vue-query, casl, vuetify
├── stores/ # Pinia stores (client state only)
└── types/ # TypeScript interfaces
```
## Reference patterns (read these for templates)
## File Templates
For working examples in the actual codebase:
### Axios Instance (ONE per app)
- **Composable pattern:** `apps/app/src/composables/api/useEvents.ts`
- **Pinia store pattern:** `apps/app/src/stores/useAuthStore.ts`
- **Page pattern:** `apps/app/src/pages/events/index.vue`
- **Form pattern:** `apps/app/src/components/events/CreateEventDialog.vue`
- **Layout pattern:** `apps/app/src/layouts/OrganizerLayout.vue`
```typescript
// src/lib/axios.ts
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/useAuthStore'
For Vuexy component selection, consult `dev-docs/VUEXY_COMPONENTS.md` — the registry of @core wrappers and patterns. Always check that registry before writing a custom component.
const api: AxiosInstance = axios.create({
baseURL: `${import.meta.env.VITE_API_URL}/api/v1`,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 30000,
})
For auth and routing, see `dev-docs/AUTH_ARCHITECTURE.md` (httpOnly cookies, dual-axios for portal-token routes, route guard logic).
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
})
## Strict rules
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
const authStore = useAuthStore()
authStore.logout()
}
return Promise.reject(error)
},
)
### TypeScript
- Use `import type { ... }` for type-only imports
- Mirror backend PHP Enums as const objects with `as const` in `apps/app/src/types/`
- Generic API response shape: `{ data: T, meta?: PaginationMeta }`
export { api }
```
### Architecture
- Components never import axios directly — always via composables
- Composables call axios via the singleton in `apps/app/src/lib/axios.ts`
- Mutations invalidate query keys after success
- No prop drilling — use Pinia stores when state crosses two component boundaries
### TypeScript Types
### UI
- Three states for every list view: **loading** (VSkeletonLoader), **error** (VAlert with retry button), **empty** (helpful message explaining what action to take)
- Custom CSS forbidden when a Vuetify utility class exists
- Tables on mobile (<768px) collapse to VList or card view — never horizontal scroll without a visual indicator
```typescript
// src/types/events.ts
### Forms
- Zod schema mirrors backend FormRequest validation
- Errors shown inline via VeeValidate's `errors` object
- Submit button disabled while `isPending`
export type EventStatus = 'draft' | 'published' | 'registration_open' | 'buildup' | 'showday' | 'teardown' | 'closed'
export type PersonStatus = 'invited' | 'applied' | 'pending' | 'approved' | 'rejected' | 'no_show'
export type BookingStatus = 'concept' | 'requested' | 'option' | 'confirmed' | 'contracted' | 'cancelled'
export type ShiftAssignmentStatus = 'pending_approval' | 'approved' | 'rejected' | 'cancelled' | 'completed'
export type CrowdSystemType = 'CREW' | 'GUEST' | 'ARTIST' | 'VOLUNTEER' | 'PRESS' | 'PARTNER' | 'SUPPLIER'
### Routing
- File-based routing via unplugin-vue-router
- Guards in `apps/app/src/plugins/1.router/guards.ts`
- Portal routes are at `/portal/*` (within apps/app), NOT a separate SPA
- Platform admin routes are at `/platform/*`, gated by `super_admin` role
export interface Organisation {
id: string
name: string
slug: string
billing_status: string
created_at: string
updated_at: string
}
## Avoid
export interface Event {
id: string
organisation_id: string
name: string
slug: string
start_date: string
end_date: string
timezone: string
status: EventStatus
status_label: string
status_color: string
festival_sections?: FestivalSection[]
persons_count?: number
created_at: string
updated_at: string
}
export interface FestivalSection {
id: string
event_id: string
name: string
sort_order: number
}
export interface TimeSlot {
id: string
event_id: string
name: string
person_type: CrowdSystemType
date: string
start_time: string
end_time: string
}
export interface Shift {
id: string
festival_section_id: string
time_slot_id: string
location_id: string | null
slots_total: number
slots_open_for_claiming: number
slots_filled: number
fill_rate: number
status: string
festival_section?: FestivalSection
time_slot?: TimeSlot
assignments?: ShiftAssignment[]
}
export interface ShiftAssignment {
id: string
shift_id: string
person_id: string
time_slot_id: string
status: ShiftAssignmentStatus
auto_approved: boolean
person?: Person
}
export interface Person {
id: string
event_id: string
crowd_type_id: string
user_id: string | null
name: string
email: string
phone: string | null
status: PersonStatus
is_blacklisted: boolean
crowd_type?: CrowdType
}
export interface CrowdType {
id: string
organisation_id: string
name: string
system_type: CrowdSystemType
color: string
icon: string
}
export interface Artist {
id: string
event_id: string
name: string
booking_status: BookingStatus
star_rating: number
}
// API response types
export interface PaginatedResponse<T> {
data: T[]
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
// Form types
export interface CreateEventData {
organisation_id: string
name: string
slug: string
start_date: string
end_date: string
timezone?: string
status?: EventStatus
}
export interface UpdateEventData extends Partial<CreateEventData> {}
```
### TanStack Query Setup
```typescript
// src/plugins/vue-query.ts
import type { VueQueryPluginOptions } from '@tanstack/vue-query'
import { QueryClient } from '@tanstack/vue-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
})
export const vueQueryPluginOptions: VueQueryPluginOptions = {
queryClient,
}
```
### Composable (useEvents)
```typescript
// src/composables/useEvents.ts
import { computed } from 'vue'
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { api } from '@/lib/axios'
import type { Event, CreateEventData, UpdateEventData, PaginatedResponse } from '@/types/events'
export function useEventList(organisationId: string) {
return useQuery({
queryKey: ['organisations', organisationId, 'events'],
queryFn: async () => {
const { data } = await api.get<PaginatedResponse<Event>>(
`/organisations/${organisationId}/events`
)
return data
},
enabled: !!organisationId,
})
}
export function useEventDetail(eventId: string) {
return useQuery({
queryKey: ['events', eventId],
queryFn: async () => {
const { data } = await api.get<{ data: Event }>(`/events/${eventId}`)
return data.data
},
enabled: !!eventId,
})
}
export function useCreateEvent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { organisationId: string; data: CreateEventData }) => {
const { data } = await api.post<{ data: Event }>(
`/organisations/${payload.organisationId}/events`,
payload.data,
)
return data.data
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ['organisations', variables.organisationId, 'events'],
})
},
})
}
export function useUpdateEvent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: { eventId: string; data: UpdateEventData }) => {
const { data } = await api.put<{ data: Event }>(
`/events/${payload.eventId}`,
payload.data,
)
return data.data
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['events', data.id] })
queryClient.invalidateQueries({
queryKey: ['organisations', data.organisation_id, 'events'],
})
},
})
}
export function useDeleteEvent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (eventId: string) => {
await api.delete(`/events/${eventId}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] })
},
})
}
```
### Pinia Store (Auth)
```typescript
// src/stores/useAuthStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/lib/axios'
import type { Organisation } from '@/types/events'
interface AuthUser {
id: string
name: string
email: string
timezone: string
locale: string
organisations: Organisation[]
event_roles: Array<{ event_id: string; role: string }>
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null)
const token = ref<string | null>(localStorage.getItem('auth_token'))
const currentOrganisationId = ref<string | null>(localStorage.getItem('current_organisation_id'))
const isAuthenticated = computed(() => !!token.value && !!user.value)
const currentOrganisation = computed(() =>
user.value?.organisations.find(o => o.id === currentOrganisationId.value) ?? null
)
async function login(email: string, password: string): Promise<boolean> {
try {
const { data } = await api.post('/auth/login', { email, password })
user.value = data.data.user
token.value = data.data.token
localStorage.setItem('auth_token', data.data.token)
if (data.data.user.organisations.length > 0) {
setCurrentOrganisation(data.data.user.organisations[0].id)
}
return true
} catch {
return false
}
}
async function fetchMe(): Promise<boolean> {
if (!token.value) return false
try {
const { data } = await api.get('/auth/me')
user.value = data.data
return true
} catch {
logout()
return false
}
}
function setCurrentOrganisation(orgId: string) {
currentOrganisationId.value = orgId
localStorage.setItem('current_organisation_id', orgId)
}
function logout() {
user.value = null
token.value = null
currentOrganisationId.value = null
localStorage.removeItem('auth_token')
localStorage.removeItem('current_organisation_id')
}
return {
user, token, currentOrganisationId,
isAuthenticated, currentOrganisation,
login, fetchMe, setCurrentOrganisation, logout,
}
})
```
### Page Component (Event List)
```vue
<!-- src/pages/events/index.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { useEventList } from '@/composables/useEvents'
const authStore = useAuthStore()
const organisationId = computed(() => authStore.currentOrganisationId ?? '')
const { data, isLoading, isError, error } = useEventList(organisationId.value)
const events = computed(() => data.value?.data ?? [])
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
draft: 'secondary',
published: 'info',
registration_open: 'primary',
buildup: 'warning',
showday: 'success',
teardown: 'warning',
closed: 'secondary',
}
return colors[status] ?? 'secondary'
}
</script>
<template>
<div>
<!-- Page Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div>
<h4 class="text-h4 mb-1">Events</h4>
<p class="text-body-1 text-medium-emphasis">
Manage events for your organisation
</p>
</div>
<VBtn
color="primary"
prepend-icon="tabler-plus"
:to="{ name: 'events-create' }"
>
Create Event
</VBtn>
</div>
<!-- Loading State -->
<VCard v-if="isLoading">
<VCardText class="text-center py-8">
<VProgressCircular indeterminate color="primary" />
</VCardText>
</VCard>
<!-- Error State -->
<VAlert v-else-if="isError" type="error" class="mb-4">
{{ error?.message ?? 'Failed to load events' }}
</VAlert>
<!-- Events Table -->
<VCard v-else>
<VDataTable
:items="events"
:headers="[
{ title: 'Name', key: 'name' },
{ title: 'Dates', key: 'start_date' },
{ title: 'Status', key: 'status' },
{ title: 'Actions', key: 'actions', sortable: false },
]"
>
<template #item.name="{ item }">
<RouterLink :to="{ name: 'events-show', params: { id: item.id } }">
{{ item.name }}
</RouterLink>
</template>
<template #item.start_date="{ item }">
{{ item.start_date }} - {{ item.end_date }}
</template>
<template #item.status="{ item }">
<VChip :color="getStatusColor(item.status)" size="small">
{{ item.status_label }}
</VChip>
</template>
<template #item.actions="{ item }">
<VBtn
icon
variant="text"
size="small"
:to="{ name: 'events-edit', params: { id: item.id } }"
>
<VIcon icon="tabler-edit" />
</VBtn>
</template>
</VDataTable>
</VCard>
</div>
</template>
```
### Navigation Menu (Organizer App)
```typescript
// src/navigation/vertical/index.ts
import type { VerticalNavItems } from '@/@layouts/types'
export default [
{
title: 'Dashboard',
to: { name: 'dashboard' },
icon: { icon: 'tabler-smart-home' },
},
{ heading: 'Event Management' },
{
title: 'Events',
to: { name: 'events' },
icon: { icon: 'tabler-calendar-event' },
},
{
title: 'Festival Sections',
to: { name: 'festival-sections' },
icon: { icon: 'tabler-layout-grid' },
},
{
title: 'Time Slots & Shifts',
to: { name: 'shifts' },
icon: { icon: 'tabler-clock' },
},
{ heading: 'People' },
{
title: 'Persons',
to: { name: 'persons' },
icon: { icon: 'tabler-users' },
},
{
title: 'Artists',
to: { name: 'artists' },
icon: { icon: 'tabler-music' },
},
{
title: 'Volunteers',
to: { name: 'volunteers' },
icon: { icon: 'tabler-heart-handshake' },
},
{ heading: 'Operations' },
{
title: 'Accreditation',
to: { name: 'accreditation' },
icon: { icon: 'tabler-id-badge-2' },
},
{
title: 'Briefings',
to: { name: 'briefings' },
icon: { icon: 'tabler-mail' },
},
{
title: 'Mission Control',
to: { name: 'mission-control' },
icon: { icon: 'tabler-broadcast' },
},
{ heading: 'Insights' },
{
title: 'Reports',
to: { name: 'reports' },
icon: { icon: 'tabler-chart-bar' },
},
] as VerticalNavItems
```
## Forms with VeeValidate + Zod
```vue
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { useCreateEvent } from '@/composables/useEvents'
const schema = toTypedSchema(
z.object({
name: z.string().min(1, 'Name is required'),
slug: z.string().min(1, 'Slug is required'),
start_date: z.string().min(1, 'Start date is required'),
end_date: z.string().min(1, 'End date is required'),
timezone: z.string().default('Europe/Amsterdam'),
})
)
const { handleSubmit, errors, defineField } = useForm({ validationSchema: schema })
const [name, nameAttrs] = defineField('name')
const [startDate, startDateAttrs] = defineField('start_date')
const { mutate: createEvent, isPending } = useCreateEvent()
const onSubmit = handleSubmit(values => {
createEvent({
organisationId: authStore.currentOrganisationId!,
data: values as CreateEventData,
})
})
</script>
<template>
<form @submit="onSubmit">
<VTextField
v-model="name"
v-bind="nameAttrs"
label="Event Name"
:error-messages="errors.name"
/>
<VTextField
v-model="startDate"
v-bind="startDateAttrs"
label="Start Date"
type="date"
:error-messages="errors.start_date"
/>
<VBtn type="submit" color="primary" :loading="isPending">
Create Event
</VBtn>
</form>
</template>
```
## Best Practices
### Always Use
- `<script setup lang="ts">` for components
- Props: `defineProps<{...}>()`
- Emits: `defineEmits<{...}>()`
- TanStack Query for all API calls via composables
- Computed properties for derived state
- Vuexy/Vuetify components (VBtn, VCard, VDataTable, VDialog, etc.)
- `import type { ... }` for type-only imports
- Status KPI tiles as clickable VCards on list pages
- VSkeleton loader during loading
- VAlert with retry on errors
- Mobile: table collapses to VList below 768px
### Avoid
- Options API
- Options API (`export default { ... }`)
- `any` types
- Raw axios calls in components (use composables)
- Inline styles (use Vuetify utility classes)
- Raw axios calls in components
- Inline styles
- Direct DOM manipulation
- Mutating props
- Prop drilling (use Pinia stores)
- Custom CSS when Vuetify class exists
## Portal Router Guards
```typescript
// apps/portal/src/router/guards.ts
export function determineAccessMode(route: RouteLocationNormalized): 'token' | 'login' | 'unauthenticated' {
if (route.query.token) return 'token'
if (authStore.isAuthenticated) return 'login'
return 'unauthenticated'
}
// Token-based: POST /api/v1/portal/token-auth { token: '...' } -> returns person context
// Login-based: Same /api/v1/auth/login as app/
```
- Custom CSS when a Vuetify class exists
- Hardcoded URLs or string-literal status values

View File

@@ -1,6 +1,6 @@
---
description: Multi-tenancy and portal architecture rules for Crewli
globs: ["api/**/*.php", "apps/portal/**/*.{vue,ts}"]
globs: ["api/**/*.php"]
alwaysApply: true
---
@@ -92,16 +92,20 @@ Route::middleware(['auth:sanctum', 'event.role:event_manager'])->group(...);
## Portal Architecture
### Two Access Modes in One App (`apps/portal/`)
### Two Access Modes Under `/portal/*` Routes (within `apps/app/`)
Post-WS-3, the portal lives in the main SPA at `/portal/*` routes.
Two access modes coexist:
| Mode | Middleware | Users | Token Source |
|------|-----------|-------|-------------|
| Login | `auth:sanctum` | Volunteers, Crew | Bearer token from login |
|------|------------|-------|--------------|
| Login | `auth:sanctum` | Volunteers, Crew | Bearer token from login (httpOnly cookie) |
| Token | `portal.token` | Artists, Suppliers, Press | URL token param: `?token=ULID` |
### Token-Based Authentication Flow
```
1. Artist/supplier receives email with link: https://portal.crewli.app/advance?token=01HQ3K...
1. Artist/supplier receives email with link: https://crewli.app/portal/advance?token=01HQ3K...
(Legacy portal.crewli.app links 301-redirect, preserving the token query param)
2. Portal detects token in URL query parameter
3. POST /api/v1/portal/token-auth { token: '01HQ3K...' }
4. Backend validates token against artists.portal_token or production_requests.token
@@ -111,9 +115,9 @@ Route::middleware(['auth:sanctum', 'event.role:event_manager'])->group(...);
### Login-Based Authentication Flow
```
1. Volunteer navigates to https://portal.crewli.app/login
1. Volunteer navigates to https://crewli.app/login
2. Enters email + password
3. POST /api/v1/auth/login (same endpoint as apps/app/)
3. POST /api/v1/auth/login
4. Returns user + organisations + event roles
5. Portal shows volunteer-specific views (My Shifts, Claim Shifts, Messages, Profile)
```
@@ -190,12 +194,11 @@ class PortalTokenMiddleware
// config/cors.php
'allowed_origins' => [
env('FRONTEND_APP_URL', 'http://localhost:5174'),
env('FRONTEND_PORTAL_URL', 'http://localhost:5175'),
],
'supports_credentials' => true,
```
Production example (subdomains on **crewli.app**): `FRONTEND_APP_URL=https://crewli.app`, `FRONTEND_PORTAL_URL=https://portal.crewli.app`, and `SANCTUM_STATEFUL_DOMAINS=crewli.app,portal.crewli.app`.
Production example (registered domain **crewli.app**): `FRONTEND_APP_URL=https://crewli.app` and `SANCTUM_STATEFUL_DOMAINS=crewli.app`. The legacy `FRONTEND_PORTAL_URL` env key is retained for outbound-email controllers (per AUTH_ARCHITECTURE.md §11), but resolves to the same host post-WS-3.
## Shift Claiming & Approval Flow

12
.gitignore vendored
View File

@@ -57,3 +57,15 @@ docs/.vitepress/cache
# Claude Project Knowledge sync output (regenerated by scripts/sync-claude-docs.sh)
.claude-sync/
# Claude Code runtime state
.claude/*.lock
# GlitchTip
docker/glitchtip/.env
backups/
# WS-7 RFC §3.5: Vite sourcemaps are uploaded to GlitchTip and stripped
# from dist/ before deploy. Defensive exclusion in case dist/ is ever
# committed by mistake (it's already covered by `dist/` above).
apps/app/dist/**/*.map

View File

@@ -1,5 +1,7 @@
# Crewli — Claude Code Instructions
> See `dev-docs/CLAUDE_CODE_TOOLING.md` for the deterministic guard-rail layer (hooks, subagent, slash commands).
## Project context
Crewli is a multi-tenant SaaS platform for event and festival management.
@@ -22,12 +24,11 @@ Design document: `/dev-docs/design-document.md`
- `composer rector` — Rector dry-run for modernisation suggestions.
See `/dev-docs/RECTOR.md`. Apply only in scoped sprints, never
automatically.
- ts-reset patches TypeScript's loosest default types in both SPAs.
- ts-reset patches TypeScript's loosest default types in the SPA.
See `/dev-docs/FRONTEND-TOOLING.md`. New TypeScript code adheres
to ts-reset's stricter types automatically.
- Vitest — `apps/portal` has 113+ tests; `apps/app` currently has
no Vitest setup (tracked as TECH-APP-VITEST, must close before
S3b lands).
- Vitest — `apps/app` has Vitest with 213 tests as of WS-3 PR-B2a.
Test count grows with each PR; check `pnpm test` for current value.
## Development tooling
@@ -38,24 +39,23 @@ Design document: `/dev-docs/design-document.md`
## Repository layout
- `api/` — Laravel backend
- `apps/app/`Organizer SPA (main product app + Platform Admin for super admins)
- `apps/portal/` — External portal (volunteers, artists, suppliers, etc.)
- `apps/app/`Single SPA covering organizers, volunteers, crew, super admins (context-routed in-app) plus the public form-fill / artist-advance flows
## Apps and portal architecture
## App architecture
- `apps/app/`Organizer: event management per organisation. Includes **Platform Admin** section (`/platform/*`) for super_admin users (organisation management, user management, impersonation, activity log).
- `apps/portal/` — External users: one app, two access modes:
- Login-based (`auth:sanctum`): volunteers, crew — persons with `user_id`
- Token-based (`portal.token` middleware): artists, suppliers, press — persons without `user_id`
`apps/app/`single workspace, two access modes:
- Login-based (`auth:sanctum`): organizers, volunteers, crew, super_admin. Includes **Platform Admin** section (`/platform/*`) for super_admin users (organisation management, user management, impersonation, activity log). Context-aware routing inside the SPA distinguishes organizer vs. volunteer experience based on `useAuthStore.availableContexts` (see `dev-docs/AUTH_ARCHITECTURE.md`).
- Token-based (`portal.token` middleware): artists, suppliers, press — persons without `user_id`. Stateless per-request token via `Authorization: Bearer` header or `?token=` query parameter.
### CORS
Configure two frontend origins in both Laravel (`config/cors.php` via env) and the Vite dev server proxy:
Single frontend origin in both Laravel (`config/cors.php` via env) and the Vite dev server proxy:
- app: `localhost:5174`
- portal: `localhost:5175`
- dev: `localhost:5174`
- prod: `https://crewli.app`
**Production (`crewli.app`):** API `https://api.crewli.app`, SPAs `https://crewli.app`, `https://portal.crewli.app` — see `api/.env.example` for `FRONTEND_*` and `SANCTUM_STATEFUL_DOMAINS`. **`crewli.nl`** is only for a future marketing site; this application stack uses **`crewli.app`** (not `.nl` for API, SPAs, or transactional mail).
See `api/.env.example` for `FRONTEND_*` and `SANCTUM_STATEFUL_DOMAINS`. **`crewli.nl`** is only for a future marketing site; this application stack uses **`crewli.app`** (not `.nl` for API, SPA, or transactional mail).
## Backend rules (strict)
@@ -236,10 +236,24 @@ you are using available components rather than building custom ones.
### Forms
- VeeValidate for form state + Zod for schema validation — always together
- Zod schemas must mirror the backend Form Request rules (field names, required/optional, types)
Canonical form pattern (used everywhere in the SPA):
- `ref({ field: ... })` for form state
- `VForm` ref + per-field rules drawn from `@core/utils/validators`
(`requiredValidator`, `emailValidator`, etc.)
- A separate `errors: Ref<Record<string, string>>` for server-validation
feedback (mapped from 422 responses)
- **Zod** for runtime validation of API payloads/responses (in
`apps/app/src/schemas/*.ts`) — Zod schemas mirror backend Form Requests
(field names, required/optional, types) and are the canonical contract
- No inline validation logic in components
VeeValidate is **NOT** the form library here. It was previously listed
but never actually adopted in any page; it was removed in commit
`<sha>` (Session 4 follow-up). Reference forms: `apps/app/src/components/sections/CreateShiftDialog.vue`,
`apps/app/src/components/timetable/AddPerformanceDialog.vue`,
`apps/app/src/pages/register/[public_token].vue`.
### Naming
- DB columns: `snake_case`

View File

@@ -1,4 +1,4 @@
.PHONY: help services services-stop api app portal docs migrate fresh db-shell test test-db-create schema-dump
.PHONY: help services services-stop services-glitchtip-status api app docs migrate fresh db-shell test test-db-create schema-dump
# Colors
GREEN := \033[0;32m
@@ -6,6 +6,10 @@ YELLOW := \033[0;33m
CYAN := \033[0;36m
NC := \033[0m
# Compose files merged for local dev. Both files share one project so
# Mailpit (bm_mailpit) is reachable from the GlitchTip containers.
COMPOSE_FILES := -f docker-compose.yml -f docker-compose.glitchtip.yml
help:
@echo ""
@echo "$(GREEN)╔══════════════════════════════════════════════════════════════╗$(NC)"
@@ -13,13 +17,13 @@ help:
@echo "$(GREEN)╚══════════════════════════════════════════════════════════════╝$(NC)"
@echo ""
@echo " $(YELLOW)Services (Docker):$(NC)"
@echo " make services Start MySQL, Redis, Mailpit"
@echo " make services-stop Stop all Docker services"
@echo " make services Start MySQL, Redis, Mailpit, GlitchTip"
@echo " make services-stop Stop all Docker services"
@echo " make services-glitchtip-status Tail GlitchTip web container logs"
@echo ""
@echo " $(YELLOW)Development Servers:$(NC)"
@echo " make api Laravel API → http://localhost:8000"
@echo " make app Organizer SPA → http://localhost:5174"
@echo " make portal Portal SPA → http://localhost:5175"
@echo " make docs VitePress docs → http://localhost:5176"
@echo ""
@echo " $(YELLOW)Database:$(NC)"
@@ -35,21 +39,27 @@ help:
services:
@echo "$(GREEN)Starting Docker services...$(NC)"
@docker compose up -d
@docker compose $(COMPOSE_FILES) up -d
@echo ""
@echo "$(GREEN)Services:$(NC)"
@echo " $(CYAN)MySQL:$(NC) localhost:3306 (crewli / secret)"
@echo " $(CYAN)Redis:$(NC) localhost:6379"
@echo " $(CYAN)Mailpit:$(NC) http://localhost:8025"
@echo " $(CYAN)MySQL:$(NC) localhost:3306 (crewli / secret)"
@echo " $(CYAN)Redis:$(NC) localhost:6379"
@echo " $(CYAN)Mailpit:$(NC) http://localhost:8025"
@echo " $(CYAN)GlitchTip:$(NC) http://localhost:8200"
@echo ""
@echo "$(YELLOW)Waiting for MySQL...$(NC)"
@until docker exec bm_mysql mysqladmin ping -h localhost -u root -proot --silent 2>/dev/null; do sleep 1; done
@echo "$(GREEN)✓ Ready!$(NC)"
@echo "$(YELLOW)Note:$(NC) GlitchTip web takes ~60s on first boot (migrations)."
@echo " Tail logs with: $(CYAN)make services-glitchtip-status$(NC)"
services-stop:
@docker compose down
@docker compose $(COMPOSE_FILES) down
@echo "$(GREEN)✓ Services stopped$(NC)"
services-glitchtip-status:
@docker compose $(COMPOSE_FILES) logs -f glitchtip-web
api:
@echo "$(GREEN)Starting Laravel API → http://localhost:8000$(NC)"
@cd api && php artisan serve
@@ -58,10 +68,6 @@ app:
@echo "$(GREEN)Starting Organizer SPA → http://localhost:5174$(NC)"
@cd apps/app && pnpm dev
portal:
@echo "$(GREEN)Starting Portal SPA → http://localhost:5175$(NC)"
@cd apps/portal && pnpm dev
docs:
@echo "$(GREEN)Starting VitePress docs → http://localhost:5176$(NC)"
@cd docs && npm run docs:dev

View File

@@ -21,10 +21,9 @@ Implementation is phased; the authoritative feature and schema list lives in the
| App | Path | Port | Role |
|-----|------|------|------|
| **Organizer** | `apps/app/` | 5174 | Main product for **org and event staff**: events, sections, shifts, people, artists, accreditation, briefings, reports. Includes **Platform Admin** section for super admins (`/platform/*`). |
| **Portal** | `apps/portal/` | 5175 | **External** users: stripped layout; login- or token-based access. |
| **SPA** | `apps/app/` | 5174 | Single-SPA product covering **organizers, volunteers, crew, super admins** (context-routed in-app), plus token-based access for artists, suppliers, press. Includes **Platform Admin** section for super admins (`/platform/*`). |
All apps talk to the API over **CORS** with **Laravel Sanctum** tokens.
The SPA talks to the API over **CORS** with **Laravel Sanctum** tokens.
---
@@ -125,13 +124,12 @@ make db-shell
| Resource | Contents |
|----------|----------|
| [resources/design/](resources/design/) | **Canonical product specs** in Markdown. Referenced by `.cursor` and `CLAUDE.md` as source of truth for features and data model: `design-document.md`, `dev-guide.md`, `start-guide.md`. |
| [.cursor/ARCHITECTURE.md](.cursor/ARCHITECTURE.md) | System diagram, apps, multi-tenancy, roles, event lifecycle, API route map, core schema overview (summarises `resources/design` when present) |
| [.cursor/instructions.md](.cursor/instructions.md) | Quick reference, phased roadmap, module build order |
| [.cursor/rules/](.cursor/rules/) | Workspace, Laravel, Vue, testing conventions |
| [docs/SETUP.md](docs/SETUP.md) | Environment and local setup |
| [docs/API.md](docs/API.md) | API notes (if maintained) |
| [docs/SCHEMA.md](docs/SCHEMA.md) | Schema notes (if maintained) |
| [CLAUDE.md](CLAUDE.md) | Project conventions, vibe-coding principles, Vuexy-first decision tree (auto-loaded by Claude Code). |
| [.cursor/rules/](.cursor/rules/) | Workspace, Laravel, Vue, testing conventions. |
| [dev-docs/SETUP.md](dev-docs/SETUP.md) | Environment and local setup. |
| [dev-docs/SCHEMA.md](dev-docs/SCHEMA.md) | Database schema (kept in sync with migrations). |
| [dev-docs/API.md](dev-docs/API.md) | API contract. |
| [dev-docs/design-document.md](dev-docs/design-document.md) | Product specification. |
---

View File

@@ -78,3 +78,14 @@ SANCTUM_STATEFUL_DOMAINS=localhost:5174,localhost:5175
# env-gate + this flag) keeps Telescope out even if one layer is
# breached. See /dev-docs/TELESCOPE.md.
TELESCOPE_ENABLED=false
# Sentry / GlitchTip (RFC-WS-7 §3.3, §3.4).
# DSN routes events to the self-hosted GlitchTip project crewli-api.
# Empty = SDK no-op — leave blank in local development. Source the real
# value from the 1Password vault entry "Crewli / GlitchTip / DSNs"
# (key SENTRY_DSN_BACKEND) for staging / production.
SENTRY_DSN_BACKEND=
# Release identifier in the form crewli-api@<short-sha>. The deploy
# pipeline injects this per build; leave blank locally. Empty release
# means events are still captured but won't carry release context.
SENTRY_RELEASE=

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
use App\Services\Artist\ArtistEngagementService;
use Illuminate\Console\Command;
/**
* RFC v0.2 daily option-expiry demotion.
*
* Finds every engagement with booking_status = Option whose
* option_expires_at has passed, transitions it to Draft via the state
* machine (which records the transition activity entry), and writes
* an additional `option_expired` activity event so the audit log can
* distinguish system-driven expiries from manual demotions.
*
* Idempotency: the state machine returns immediately when the
* engagement is no longer in Option (e.g. another run already
* demoted it), so a second run within the same minute is a no-op
* for any given engagement.
*
* Notification: notification framework lands post-Accreditation. For
* Session 2 the command writes activity log only; emailing the
* project leader is tracked under BACKLOG entry
* ART-DEMOTE-NOTIFICATION.
*/
final class DemoteExpiredOptions extends Command
{
protected $signature = 'artist:demote-expired-options';
protected $description = 'Demote ArtistEngagement rows whose option_expires_at has passed back to Draft.';
public function handle(ArtistEngagementService $service): int
{
$expired = ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->where('booking_status', ArtistEngagementStatus::Option->value)
->whereNotNull('option_expires_at')
->where('option_expires_at', '<=', now())
->whereNull('deleted_at')
->get();
$demotedIds = [];
foreach ($expired as $engagement) {
// Re-check status under fresh state — another worker / a
// user UI action may have already transitioned this row.
if ($engagement->booking_status !== ArtistEngagementStatus::Option) {
continue;
}
$service->transitionStatus($engagement, ArtistEngagementStatus::Draft);
activity('artist_engagement')
->performedOn($engagement)
->event('option_expired')
->withProperties([
'organisation_id' => $engagement->organisation_id,
'event_id' => $engagement->event_id,
'option_expires_at' => optional($engagement->option_expires_at)->toIso8601String(),
])
->log('option_expired');
$demotedIds[] = (string) $engagement->id;
}
$count = count($demotedIds);
$this->info("Demoted {$count} option(s) on ".now()->toDateString().'.');
if ($count > 0) {
$this->line('IDs: '.implode(', ', $demotedIds));
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\Organisation;
use Illuminate\Console\Command;
/**
* Seed the default artist_advance FormSchema for one organisation
* (by id) or for every organisation.
*
* The OrganisationObserver wires this for new tenants automatically;
* this command exists to backfill organisations that pre-date the
* RFC-TIMETABLE v0.2 D15 default. Idempotent orgs that already own
* an artist_advance schema are skipped.
*/
final class SeedArtistAdvanceDefaultCommand extends Command
{
protected $signature = 'artist:seed-advance-default {organisation? : Organisation ID; omit to seed every organisation}';
protected $description = 'Seed the default artist_advance FormSchema for one or every organisation.';
public function handle(): int
{
$organisationId = $this->argument('organisation');
$query = Organisation::query();
if (is_string($organisationId) && $organisationId !== '') {
$query->whereKey($organisationId);
}
$organisations = $query->get();
if ($organisations->isEmpty()) {
$this->error('No organisations matched the supplied filter.');
return self::FAILURE;
}
foreach ($organisations as $organisation) {
ArtistAdvanceDefault::seedFor($organisation);
$this->line(sprintf(' ✓ %s (%s)', $organisation->name, $organisation->id));
}
$this->info(sprintf('Seeded artist_advance defaults for %d organisation(s).', $organisations->count()));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Enums\Artist;
/**
* Submission lifecycle status for an AdvanceSection.
*
* Per RFC-TIMETABLE v0.2 §5.3 (`advance_sections.submission_status`
* column).
*/
enum AdvanceSectionSubmissionStatus: string
{
case Open = 'open';
case Pending = 'pending';
case Submitted = 'submitted';
case Approved = 'approved';
case Declined = 'declined';
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Enums\Artist;
/**
* Type-categorisation for an AdvanceSection.
*
* Per RFC-TIMETABLE v0.2 §5.3 (`advance_sections.type` column).
* Section labels live in the `name` column; this enum classifies
* the section for downstream behaviour (rendering, defaults).
*/
enum AdvanceSectionType: string
{
case GuestList = 'guest_list';
case Contacts = 'contacts';
case Production = 'production';
case Custom = 'custom';
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enums\Artist;
/**
* Review status for an individual AdvanceSubmission row.
*
* Per RFC-TIMETABLE v0.2 §5.3 (`advance_submissions.status`).
*/
enum AdvanceSubmissionStatus: string
{
case Pending = 'pending';
case Accepted = 'accepted';
case Declined = 'declined';
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Enums\Artist;
/**
* Booking status for an ArtistEngagement (per-event booking).
*
* Per RFC-TIMETABLE v0.2 D9 9 states. `Cancelled`, `Rejected`,
* `Declined` are three distinct end-states for reporting.
*/
enum ArtistEngagementStatus: string
{
case Draft = 'draft';
case Requested = 'requested';
case Option = 'option';
case Offered = 'offered';
case Confirmed = 'confirmed';
case Contracted = 'contracted';
case Cancelled = 'cancelled';
case Rejected = 'rejected';
case Declined = 'declined';
public function label(): string
{
return match ($this) {
self::Draft => 'Concept',
self::Requested => 'Aangevraagd',
self::Option => 'Optie',
self::Offered => 'Aanbod uit',
self::Confirmed => 'Bevestigd',
self::Contracted => 'Gecontracteerd',
self::Cancelled => 'Geannuleerd',
self::Rejected => 'Afgewezen',
self::Declined => 'Bedankt',
};
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Enums\Artist;
/**
* Who handles BUMA reporting/payment for a given engagement.
*
* Per RFC-TIMETABLE v0.2 D26.
*/
enum BumaHandledBy: string
{
case Organisation = 'organisation';
case BookingAgency = 'booking_agency';
case NotApplicable = 'not_applicable';
public function label(): string
{
return match ($this) {
self::Organisation => 'Organisatie',
self::BookingAgency => 'Boekingsagent',
self::NotApplicable => 'Niet van toepassing',
};
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Enums\Artist;
/**
* Deal-fee structure for an ArtistEngagement.
*
* Per RFC-TIMETABLE v0.2 §5.3 (`fee_type` column).
*/
enum FeeType: string
{
case Flat = 'flat';
case DoorSplit = 'door_split';
case GuaranteePlusSplit = 'guarantee_plus_split';
public function label(): string
{
return match ($this) {
self::Flat => 'Vaste fee',
self::DoorSplit => 'Door split',
self::GuaranteePlusSplit => 'Garantie + split',
};
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Enums\Artist;
/**
* Payment progress for an ArtistEngagement.
*
* Per RFC-TIMETABLE v0.2 §5.3 (`payment_status` column).
*/
enum PaymentStatus: string
{
case None = 'none';
case DepositPaid = 'deposit_paid';
case PaidInFull = 'paid_in_full';
public function label(): string
{
return match ($this) {
self::None => 'Geen betaling',
self::DepositPaid => 'Aanbetaling voldaan',
self::PaidInFull => 'Volledig voldaan',
};
}
}

View File

@@ -37,4 +37,31 @@ enum FormFieldBindingMergeStrategy: string
{
return $this !== self::Append;
}
/**
* Whether this strategy is structurally valid against the given target
* type.
*
* Per RFC-WS-6 §V1 + ARCH-BINDINGS §4.2 (strategy x target-type validity matrix).
*
* | SCALAR | COLLECTION | RELATION |
* Overwrite | valid | valid* | valid |
* Append | INVALID| valid | INVALID |
* Replace | valid | valid | valid |
* FirstWriteWins | valid | valid | valid |
*
* * unusual but valid (overwrites entire collection)
*
* The PublishGuard AppendStrategyRequiresCollectionTarget uses this
* method to validate at publish time. Append on scalars is rejected
* because it requires a fingerprint mechanism for retry-idempotency
* that would embed implementation detail in domain data.
*/
public function validForTargetType(BindingTargetType $type): bool
{
return match ($this) {
self::Append => $type === BindingTargetType::COLLECTION,
default => true,
};
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Enums\Observability;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
/**
* Actor classification used as the `actor_type` Sentry tag (RFC-WS-7 §3.6).
*
* Resolution precedence (most specific first):
* 1. Portal-token request PORTAL_TOKEN
* 2. Authenticated super_admin SUPER_ADMIN
* 3. Authenticated org_admin ORGANIZER_ADMIN
* 4. Other authenticated user ORG_MEMBER
* 5. None of the above UNAUTHENTICATED
*
* Crewli has no dedicated `volunteer` Spatie role today; volunteer-ness is
* behaviour (a user has shift assignments) rather than identity. A
* dedicated VOLUNTEER actor_type case will land alongside that role split
* if/when it is introduced (BACKLOG OBS-1).
*/
enum ActorType: string
{
case ORGANIZER_ADMIN = 'organizer_admin';
case SUPER_ADMIN = 'super_admin';
case PORTAL_TOKEN = 'portal_token';
case ORG_MEMBER = 'org_member';
case UNAUTHENTICATED = 'unauthenticated';
public static function resolve(?Authenticatable $user, ?Request $request): self
{
if ($request !== null && $request->attributes->get('portal_context') !== null) {
return self::PORTAL_TOKEN;
}
if (! $user instanceof User) {
return self::UNAUTHENTICATED;
}
if ($user->hasRole('super_admin')) {
return self::SUPER_ADMIN;
}
if ($user->hasRole('org_admin')) {
return self::ORGANIZER_ADMIN;
}
return self::ORG_MEMBER;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Events\FormBuilder;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Broadcast when TriggerPersonIdentityMatchOnFormSubmit (D2) writes the
* final identity_match_status. Frontend portal IdentityMatchBanner
* subscribes to this channel and refetches the submission resource on
* receipt, so the banner copy transitions from "we're checking matches…"
* to the final state without a manual reload.
*
* Per RFC-WS-6 §Q1 v1.3 addition 2.
*
* Wiring (the dispatch call from TriggerPersonIdentityMatchOnFormSubmit::handle)
* lands in D2. This class exists in D1 so D2's wiring is a one-liner.
*/
final class FormSubmissionIdentityMatchResolved implements ShouldBroadcast
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public readonly string $submissionId,
public readonly string $status, // 'matched' | 'no_match' | 'multiple_candidates'
public readonly int $matchCount,
) {}
/**
* Private channel keyed on submission ULID.
*
* Frontend subscribes via `Echo.private('submission.${submissionId}')`.
* Authorization (only the submitter / organisation admins can subscribe)
* is the responsibility of the channel-authorization callback in
* routes/channels.php that wiring lands in D2 alongside the dispatch.
*
* @return array<int, PrivateChannel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("submission.{$this->submissionId}"),
];
}
public function broadcastAs(): string
{
return 'identity-match.resolved';
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by ArtistResolver::fromPortalToken when the engagement's
* portal_token matches but the master Artist has been soft-deleted.
* Per RFC v0.2 D27 the engagement itself remains usable; the portal
* flow surfaces a clear 410 Gone rather than crashing on a null
* subject downstream.
*/
final class ArtistDeletedException extends DomainException
{
public function __construct(public readonly string $engagementId)
{
parent::__construct(sprintf(
'Master Artist for engagement %s has been deleted; portal flow is not available.',
$engagementId,
));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Models\ArtistEngagement;
use DomainException;
/**
* Raised when an ArtistEngagement is being created with an artist and
* event that belong to different organisations. The engagement's
* `organisation_id` is denormalised from the artist (RFC v0.2 D10);
* the event must match. Cross-tenant linkage is a hard error fail
* loud rather than silently denormalise the wrong tenant.
*/
final class CrossTenantEngagementException extends DomainException
{
public static function forEngagement(ArtistEngagement $engagement): self
{
return new self(sprintf(
'ArtistEngagement cross-tenant: artist=%s (org=%s) vs event=%s (org=%s).',
$engagement->artist_id ?? 'null',
$engagement->artist?->organisation_id ?? 'null',
$engagement->event_id ?? 'null',
$engagement->event?->organisation_id ?? 'null',
));
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Models\Artist;
use DomainException;
/**
* Raised by ArtistService::create when an exact-name match (case-insensitive)
* already exists in the same organisation. The handler can surface the
* existing artist's id to the UI so a "use existing or rename" choice is
* presented to the booker.
*/
final class DuplicateArtistException extends DomainException
{
public function __construct(
public readonly Artist $existing,
string $message = 'An artist with this name already exists in this organisation.',
) {
parent::__construct($message);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Models\Genre;
use DomainException;
/**
* Raised by GenreService::delete when artists still reference this genre
* via `default_genre_id`. The frontend must offer to re-bind those
* artists to a different genre (or null) before retrying.
*/
final class GenreInUseException extends DomainException
{
public function __construct(
public readonly Genre $genre,
public readonly int $referencingArtistsCount,
) {
parent::__construct(sprintf(
'Genre "%s" cannot be deleted: %d artist(s) reference it as their default genre.',
$genre->name,
$referencingArtistsCount,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by ArtistResolver::fromPortalToken when the supplied portal
* token does not match any active artist_engagements row. Maps to a
* 404 at the HTTP boundary distinguishes from ArtistDeletedException
* (engagement exists but master Artist is soft-deleted, 410 Gone).
*/
final class InvalidPortalTokenException extends DomainException
{
public static function create(): self
{
return new self('Portal token does not resolve to an active engagement.');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use DomainException;
/**
* Raised when a booking_status transition is rejected by the
* ArtistEngagement state machine (RFC v0.2 §10.1).
*/
final class InvalidStatusTransitionException extends DomainException
{
public function __construct(
public readonly ArtistEngagementStatus $from,
public readonly ArtistEngagementStatus $to,
public readonly string $reason,
) {
parent::__construct(sprintf(
'Invalid booking_status transition %s → %s: %s',
$from->value,
$to->value,
$reason,
));
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by StageDayService::replaceDays when the proposed event_ids
* would remove a day that still has non-cancelled performances scheduled
* on it. The frontend re-prompts the user with a confirmation dialog
* and re-submits with `?force_orphan=true` to acknowledge the orphans.
*
* Controller maps to HTTP 409 with body
* `{conflict: 'orphaned_performances', performances_on_removed_events: [...]}`.
*/
final class StageDaysOrphanedPerformancesException extends DomainException
{
/**
* @param array<int, string> $performanceIds
* @param array<int, string> $removedEventIds
*/
public function __construct(
public readonly array $performanceIds,
public readonly array $removedEventIds,
) {
parent::__construct(sprintf(
'Stage-day removal would orphan %d performance(s) on %d event(s).',
count($performanceIds),
count($removedEventIds),
));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by LaneCascadeService::move when the client-supplied version
* does not match the row's current version (RFC v0.2 D14 optimistic
* locking on Performance). Controller maps to HTTP 409 with body
* `{conflict: 'version_mismatch', current_version: N, server_data: …}`.
*/
final class VersionMismatchException extends DomainException
{
public function __construct(
public readonly int $currentVersion,
public readonly int $clientVersion,
) {
parent::__construct(sprintf(
'Performance version mismatch: server=%d, client=%d.',
$currentVersion,
$clientVersion,
));
}
}

View File

@@ -5,19 +5,48 @@ declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use RuntimeException;
use Throwable;
/**
* RFC-WS-6 §3 (Q3) catastrophic applicator failure that bubbles to
* the caller. Per-binding failures are captured in BindingPassResult,
* not thrown.
* Base for all FormBindingApplicator-pipeline exceptions.
*
* Subclasses provide a `reasonCode()` that maps to:
* - failure_response_code on form_submissions (response-shape driver)
* - HTTP status code (422 / 503 / 500)
* - user-facing copy class (rendered by frontend)
*
* Per RFC-WS-6 §Q3 v1.3 addition 2.
*
* Concrete subclasses:
* - FormBindingSchemaConfigException schema misconfiguration (422, schema_config_error)
* - FormBindingInfraException infra issue, retryable (503, temporary_error)
* - FormBindingApplicatorTimeoutException deadline-wrapper exceeded (extends Infra)
* - FormBindingDataIntegrityException data shape violation (422, data_integrity_error)
*
* The classifier (FormBindingExceptionClassifier) maps unknown Throwables
* to 'unknown_error' that is the fallback for anything not in this
* hierarchy.
*
* `submissionId` is preserved as a public readonly property so D2's
* outer-transaction handler can structurally read it when writing the
* `form_submission_action_failures.context` JSON, instead of regex-parsing
* the message string.
*/
final class FormBindingApplicatorException extends RuntimeException
abstract class FormBindingApplicatorException extends RuntimeException
{
public function __construct(
public readonly string $reasonCode,
public readonly string $submissionId,
?string $message = null,
string $message,
?Throwable $previous = null,
) {
parent::__construct($message ?? "FormBindingApplicator failed: {$reasonCode} (submission {$submissionId})");
parent::__construct($message, 0, $previous);
}
/**
* Response-shape classification token. One of:
* - 'schema_config_error'
* - 'temporary_error'
* - 'data_integrity_error'
*/
abstract public function reasonCode(): string;
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
/**
* Thrown by the deadline-wrapper around FormBindingApplicator when the
* inner transaction takes longer than config('form_builder.apply_deadline_seconds')
* (default 5).
*
* Extends FormBindingInfraException because from the user's perspective a
* timeout is identical to any other temporary infra issue: retry should
* help. The response-shape classification 'temporary_error' is inherited.
*
* Per RFC-WS-6 §Q1 v1.3 addition 4.
*
* The deadline-wrapper itself lands in D2 (modification to
* ApplyBindingsOnFormSubmit's apply call site). This class exists in D1
* so D2's wiring is straightforward.
*/
final class FormBindingApplicatorTimeoutException extends FormBindingInfraException
{
// No reasonCode override — inherits 'temporary_error' from FormBindingInfraException.
// No constructor override — inherits (submissionId, message, previous) from the base.
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
/**
* Data shape violation during binding apply.
*
* Examples: type mismatch between form_value and target attribute;
* foreign-key violation when the target references a soft-deleted entity;
* attempt to write to a column that has a unique constraint already
* violated by another row outside this submission.
*
* User-perceptible the same as schema_config (organiser fix needed); admin
* sees the difference via the exception class on the action-failures row.
*
* Maps to HTTP 422, failure_response_code='data_integrity_error'.
*
* Per RFC-WS-6 §Q3 v1.3 addition 2.
*/
final class FormBindingDataIntegrityException extends FormBindingApplicatorException
{
public function reasonCode(): string
{
return 'data_integrity_error';
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
/**
* Infrastructure issue during binding apply.
*
* Examples: database connection lost, lock-for-update wait exceeded,
* race condition surfaced as a constraint violation that retry would
* resolve, applicator invoked outside a DB transaction (developer-error
* surfacing as infra-triage workflow).
*
* Maps to HTTP 503, failure_response_code='temporary_error'.
* User-facing copy: "Temporary issue, please try again." with retry-after.
*
* NOT final FormBindingApplicatorTimeoutException extends this class.
*
* Per RFC-WS-6 §Q3 v1.3 addition 2.
*/
class FormBindingInfraException extends FormBindingApplicatorException
{
public function reasonCode(): string
{
return 'temporary_error';
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
/**
* Schema misconfiguration that publish-guards missed.
*
* Examples: column renamed without schema invalidation; binding target
* attribute that no longer exists on the entity; merge_strategy that's
* structurally invalid for the target type but passed publish for some
* reason; unregistered purpose value; null schema relation.
*
* Maps to HTTP 422, failure_response_code='schema_config_error'.
* User-facing copy: "This form has a configuration issue. Please contact
* the organiser. Reference: F-{ulid}"
*
* Per RFC-WS-6 §Q3 v1.3 addition 2.
*/
final class FormBindingSchemaConfigException extends FormBindingApplicatorException
{
public function reasonCode(): string
{
return 'schema_config_error';
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use DomainException;
/**
* Thrown by TriggerPersonIdentityMatchOnFormSubmit when the post-ApplyBindings
* invariant breaks.
*
* Per RFC-WS-6 §Q2 + ARCH-BINDINGS §7.3:
* Post ApplyBindingsOnFormSubmit::handle for event_registration purpose:
* subject_type='person' AND subject_id IS NOT NULL,
* OR apply_status=ApplyStatus::FAILED.
* No third state exists. Violation is a structural defect.
*
* This is NOT a FormBindingApplicatorException the listener that throws
* this does not run inside the FormBindingApplicator pipeline. It indicates
* the pipeline succeeded according to apply_status but produced incoherent
* subject state, which means a publish-guard gap or a race with manual
* data manipulation.
*
* Routed via Laravel queue worker GlitchTip + form_submission_action_failures
* row (the catch + outer-transaction handler in TriggerPersonIdentityMatchOnFormSubmit
* mirrors ApplyBindingsOnFormSubmit's pattern). Wiring lands in D2.
*/
final class IdentityMatchInvariantViolation extends DomainException
{
// Plain DomainException subclass. No reasonCode — when this fires,
// 'unknown_error' (the classifier's default fallback) is the right
// response-shape because users cannot meaningfully act on it; admins
// triage via GlitchTip. D2's invariant-throw path writes its own
// context JSON to form_submission_action_failures.
}

View File

@@ -7,6 +7,9 @@ namespace App\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
use App\Exceptions\FormBuilder\FormBindingApplicatorTimeoutException;
use App\Exceptions\FormBuilder\FormBindingInfraException;
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Database\Eloquent\Model;
@@ -26,6 +29,10 @@ use Throwable;
* - Q9: subject resolution via per-purpose PurposeSubjectResolver.
* - Q10: optional sectionId for future section-level apply.
* - Q12: hierarchical activity log via BindingActivityLogger.
* - v1.3 Q1 add 4: optional deadline (withDeadline()) soft post-call
* microtime check throwing FormBindingApplicatorTimeoutException.
* Cannot interrupt mid-query; intended to catch the long-tail of
* slow applies before they hang the public flow.
*/
// Not final + not readonly: listener tests need to override `apply()` for
// throw-path coverage (Mockery can't mock final classes; PHP doesn't allow
@@ -33,6 +40,15 @@ use Throwable;
// individually to preserve immutability.
class FormBindingApplicator
{
/**
* Per RFC-WS-6 §Q1 v1.3 addition 4 soft deadline (seconds). NULL
* means "no deadline check" (default). Set via withDeadline() so the
* value travels with a clone and the original instance stays
* deadline-free for other callers (e.g. the retry-service path,
* which currently does not bound apply() see ARCH-BINDINGS §5.3).
*/
private ?int $deadlineSeconds = null;
public function __construct(
private readonly PurposeRegistry $purposeRegistry,
private readonly BindingConflictResolver $conflictResolver,
@@ -40,33 +56,54 @@ class FormBindingApplicator
private readonly BindingActivityLogger $activityLogger,
) {}
/**
* Returns a clone configured to throw FormBindingApplicatorTimeoutException
* if apply() exceeds the given deadline.
*
* Per RFC-WS-6 §Q1 v1.3 addition 4 + ARCH-BINDINGS §5.3.
*
* Implementation note: this is a soft post-call deadline check via
* microtime. It cannot interrupt mid-query for that, configure MySQL
* connection timeouts at the database driver level. The soft deadline
* is sufficient to prevent runaway apply() calls from hanging the
* public flow indefinitely; a typical apply() takes <100ms, so a 5s
* deadline catches the long tail.
*/
public function withDeadline(int $seconds): self
{
$clone = clone $this;
$clone->deadlineSeconds = $seconds;
return $clone;
}
/**
* @throws FormBindingApplicatorException
*/
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{
$start = microtime(true);
if (DB::transactionLevel() < 1) {
throw new FormBindingApplicatorException(
'no_transaction',
(string) $submission->id,
'FormBindingApplicator must be invoked inside DB::transaction',
throw new FormBindingInfraException(
submissionId: (string) $submission->id,
message: 'FormBindingApplicator must be invoked inside DB::transaction',
);
}
/** @var \App\Models\FormBuilder\FormSchema|null $schema */
$schema = $submission->schema;
if ($schema === null) {
throw new FormBindingApplicatorException(
'no_schema',
(string) $submission->id,
throw new FormBindingSchemaConfigException(
submissionId: (string) $submission->id,
message: "schema null for submission {$submission->id}",
);
}
$purposeValue = $schema->purpose->value;
if (! $this->purposeRegistry->has($purposeValue)) {
throw new FormBindingApplicatorException(
'unknown_purpose',
(string) $submission->id,
"purpose '{$purposeValue}' not registered",
throw new FormBindingSchemaConfigException(
submissionId: (string) $submission->id,
message: "purpose '{$purposeValue}' not registered",
);
}
@@ -81,38 +118,61 @@ class FormBindingApplicator
provisionedSubjectId: null,
applications: [],
);
$this->activityLogger->logPass($submission, $result);
} else {
$resolved = $this->conflictResolver->resolve($submission, $sectionId);
return $result;
}
$resolved = $this->conflictResolver->resolve($submission, $sectionId);
// Persist subject identity for the result + apply each binding.
$applications = [];
foreach ($resolved as $binding) {
// Skip identity-key bindings — the resolver already used them
// for subject lookup in EventRegistration's PersonProvisioner
// path. Writing them again is a no-op at best, a clobber at
// worst.
if ($binding->isIdentityKey) {
continue;
// Persist subject identity for the result + apply each binding.
$applications = [];
foreach ($resolved as $binding) {
// Skip identity-key bindings — the resolver already used them
// for subject lookup in EventRegistration's PersonProvisioner
// path. Writing them again is a no-op at best, a clobber at
// worst.
if ($binding->isIdentityKey) {
continue;
}
$applications[] = $this->applyOne($subject, $binding);
}
$applications[] = $this->applyOne($subject, $binding);
}
$result = new BindingPassResult(
formSubmissionId: (string) $submission->id,
provisionedSubjectType: $this->morphAlias($subject),
provisionedSubjectId: (string) $subject->getKey(),
applications: $applications,
);
$result = new BindingPassResult(
formSubmissionId: (string) $submission->id,
provisionedSubjectType: $this->morphAlias($subject),
provisionedSubjectId: (string) $subject->getKey(),
applications: $applications,
);
}
$this->activityLogger->logPass($submission, $result);
$this->checkDeadline((string) $submission->id, $start);
return $result;
}
/**
* Throws FormBindingApplicatorTimeoutException if a deadline is
* configured and the elapsed wall-clock time exceeds it.
*/
private function checkDeadline(string $submissionId, float $startMicrotime): void
{
if ($this->deadlineSeconds === null) {
return;
}
$elapsed = microtime(true) - $startMicrotime;
if ($elapsed > $this->deadlineSeconds) {
throw new FormBindingApplicatorTimeoutException(
submissionId: $submissionId,
message: sprintf(
'FormBindingApplicator exceeded deadline of %ds (elapsed: %.2fs) for submission %s',
$this->deadlineSeconds,
$elapsed,
$submissionId,
),
);
}
}
private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult
{
try {
@@ -168,6 +228,7 @@ class FormBindingApplicator
// Per-strategy matrix. RFC §3 Q7.
if ($newValue === null) {
$behaviour = $strategy->nullWinnerBehaviour();
return match ($behaviour) {
'write' => null,
'noop' => self::NO_OP,

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Bindings;
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
use Throwable;
/**
* Maps a Throwable to a failure_response_code string.
*
* Used by both ApplyBindingsOnFormSubmit::handle's catch block (D2) and
* FormFailureRetryService::recordFailure (D2 update). Centralised so the
* listener and the retry-service produce identical classifications and a
* single behaviour change requires a single edit.
*
* Resolution order:
* 1. If the Throwable is a FormBindingApplicatorException, return its reasonCode().
* Subclass dispatch handles SchemaConfig / Infra / DataIntegrity / Timeout
* (Timeout extends Infra so it inherits 'temporary_error').
* 2. Otherwise, return 'unknown_error' anything outside the hierarchy
* (database connection lost not surfaced as Infra, generic RuntimeException
* from a non-applicator code path, IdentityMatchInvariantViolation if it
* somehow leaks here) is unknown from the response-shape perspective.
*
* Per RFC-WS-6 §Q3 v1.3 addition 2.
*/
final class FormBindingExceptionClassifier
{
public static function classify(Throwable $exception): string
{
if ($exception instanceof FormBindingApplicatorException) {
return $exception->reasonCode();
}
return 'unknown_error';
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Defaults;
use App\Enums\Artist\AdvanceSectionType;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Support\Facades\DB;
/**
* Default `artist_advance` FormSchema bootstrap per RFC-TIMETABLE
* v0.2 D15. One schema per organisation, with five sections mapped
* to AdvanceSectionType:
*
* - General Info Custom
* - Contacts Contacts
* - Production Production
* - Technical Rider Production
* - Hospitality Custom
*
* Each section carries 3-4 illustrative fields. Organisations
* customise via the FormBuilder UI later. The schema is published
* and uses section_level_submit per ARCH-FORM-BUILDER §3.2.5.
*
* Idempotent: if an organisation already owns an artist_advance
* schema (any one), the seeder no-ops and returns the existing row.
*
* Bridge to per-engagement AdvanceSection rows: FormSchemaSection
* slug matches AdvanceSectionType::value (where applicable). Sections
* carrying type=Custom use a stable slug per row name.
*/
final class ArtistAdvanceDefault
{
public static function seedFor(Organisation $organisation): FormSchema
{
$existing = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $organisation->id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->first();
if ($existing instanceof FormSchema) {
return $existing;
}
return DB::transaction(static function () use ($organisation): FormSchema {
$schema = FormSchema::create([
'organisation_id' => $organisation->id,
'owner_type' => 'organisation',
'owner_id' => $organisation->id,
'name' => 'Artiest advance',
'slug' => 'artiest-advance',
'purpose' => FormPurpose::ARTIST_ADVANCE->value,
'description' => 'Standaard advance-formulier voor artiesten. Pas de secties en velden aan via de FormBuilder.',
'is_published' => true,
'submission_mode' => FormSubmissionMode::DRAFT_SINGLE->value,
'locale' => 'nl',
'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT->value,
'freeze_on_submit' => false,
'section_level_submit' => true,
'auto_save_enabled' => true,
'version' => 1,
]);
foreach (self::sectionDefinitions() as $sortOrder => $def) {
$section = FormSchemaSection::create([
'form_schema_id' => $schema->id,
'slug' => $def['slug'],
'name' => $def['name'],
'sort_order' => $sortOrder + 1,
'submit_independent' => true,
'required_for_schema_submit' => true,
]);
foreach ($def['fields'] as $fieldOrder => $field) {
FormField::create([
'form_schema_id' => $schema->id,
'form_schema_section_id' => $section->id,
'field_type' => $field['type']->value,
'slug' => $field['slug'],
'label' => $field['label'],
'help_text' => $field['help_text'] ?? null,
'is_required' => $field['is_required'] ?? false,
'is_filterable' => false,
'is_portal_visible' => true,
'is_admin_only' => false,
'is_pii' => $field['is_pii'] ?? false,
'display_width' => $field['display_width'] ?? 'full',
'value_storage_hint' => ($field['type']->recommendedValueStorageHint())->value,
'sort_order' => $fieldOrder + 1,
]);
}
}
return $schema->refresh();
});
}
/**
* @return array<int, array{
* slug: string,
* name: string,
* advance_type: AdvanceSectionType,
* fields: array<int, array{
* type: FormFieldType,
* slug: string,
* label: string,
* help_text?: string,
* is_required?: bool,
* is_pii?: bool,
* display_width?: string,
* }>
* }>
*/
private static function sectionDefinitions(): array
{
return [
[
'slug' => 'general-info',
'name' => 'Algemeen',
'advance_type' => AdvanceSectionType::Custom,
'fields' => [
['type' => FormFieldType::DATETIME, 'slug' => 'arrival-datetime', 'label' => 'Aankomsttijd', 'is_required' => true, 'display_width' => 'half'],
['type' => FormFieldType::DATETIME, 'slug' => 'departure-datetime', 'label' => 'Vertrektijd', 'is_required' => true, 'display_width' => 'half'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'general-notes', 'label' => 'Opmerkingen'],
],
],
[
'slug' => 'contacts',
'name' => 'Contactpersonen',
'advance_type' => AdvanceSectionType::Contacts,
'fields' => [
['type' => FormFieldType::TEXT, 'slug' => 'tour-manager-name', 'label' => 'Tour manager', 'is_required' => true, 'is_pii' => true, 'display_width' => 'full'],
['type' => FormFieldType::EMAIL, 'slug' => 'tour-manager-email', 'label' => 'E-mail tour manager', 'is_required' => true, 'is_pii' => true, 'display_width' => 'half'],
['type' => FormFieldType::PHONE, 'slug' => 'tour-manager-phone', 'label' => 'Telefoon tour manager', 'is_pii' => true, 'display_width' => 'half'],
['type' => FormFieldType::TABLE_ROWS, 'slug' => 'additional-contacts', 'label' => 'Aanvullende contactpersonen', 'is_pii' => true],
],
],
[
'slug' => 'production',
'name' => 'Productie',
'advance_type' => AdvanceSectionType::Production,
'fields' => [
['type' => FormFieldType::FILE_UPLOAD, 'slug' => 'stage-plot', 'label' => 'Stage plot'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'monitor-needs', 'label' => 'Monitorwensen'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'special-equipment', 'label' => 'Specifieke apparatuur'],
],
],
[
'slug' => 'technical-rider',
'name' => 'Technische rider',
'advance_type' => AdvanceSectionType::Production,
'fields' => [
['type' => FormFieldType::FILE_UPLOAD, 'slug' => 'input-list', 'label' => 'Input list'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'microphone-preferences', 'label' => 'Microfoonvoorkeuren'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'backline-requirements', 'label' => 'Backline'],
],
],
[
'slug' => 'hospitality',
'name' => 'Hospitality',
'advance_type' => AdvanceSectionType::Custom,
'fields' => [
['type' => FormFieldType::TEXTAREA, 'slug' => 'dressing-room-requirements', 'label' => 'Kleedkamer'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'food-preferences', 'label' => 'Cateringvoorkeuren'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'drinks', 'label' => 'Drankvoorkeuren'],
['type' => FormFieldType::TEXT, 'slug' => 'allergies', 'label' => 'Allergieën', 'is_pii' => true],
],
],
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Resolvers;
use App\Exceptions\Artist\ArtistDeletedException;
use App\Exceptions\Artist\InvalidPortalTokenException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
/**
* Engagement-scoped subject resolution for the artist_advance portal
* flow. Per ARCH-FORM-BUILDER §17.3 footnote and RFC-TIMETABLE v0.2
* D15: the master Artist is the FormSubmission subject (subject_type
* = 'artist'), but the engagement provides the event_id (denormalised
* onto form_submissions per WS-4) and any advance_section context.
*
* The portal token itself is stored on artist_engagements.portal_token
* as a SHA-256 hex digest (Session 1 commit eb6d396). Callers pass
* the plaintext token; we hash and look up.
*
* This resolver is the single shared helper for portal-token
* engagement resolution. PortalTokenMiddleware delegates to it; the
* EngagementPortalController calls it directly to produce the value
* object the FormSubmissionService needs.
*/
final class ArtistResolver
{
public function fromPortalToken(string $portalToken): ArtistResolverResult
{
$digest = hash('sha256', $portalToken);
$engagement = ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->where('portal_token', $digest)
->first();
if ($engagement === null) {
throw InvalidPortalTokenException::create();
}
$artist = Artist::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->artist_id)
->first();
if (! $artist instanceof Artist) {
throw new ArtistDeletedException((string) $engagement->id);
}
return new ArtistResolverResult(
subject: $artist,
eventId: (string) $engagement->event_id,
engagement: $engagement,
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Resolvers;
use App\Models\Artist;
use App\Models\ArtistEngagement;
/**
* Value object returned by ArtistResolver::fromPortalToken.
*
* Per ARCH-FORM-BUILDER §17.3 footnote: artist_advance submissions use
* the master Artist as `subject` (preserves form_submissions.subject_type
* = 'artist'); `eventId` populates form_submissions.event_id per WS-4
* denormalisation; the engagement itself is returned so callers
* (controllers, listeners) can resolve advance_section context without
* a second query.
*/
final readonly class ArtistResolverResult
{
public function __construct(
public Artist $subject,
public string $eventId,
public ArtistEngagement $engagement,
) {}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Artist;
use App\Exceptions\Artist\DuplicateArtistException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Artist\CreateArtistRequest;
use App\Http\Requests\Api\V1\Artist\UpdateArtistRequest;
use App\Http\Resources\Api\V1\Artist\ArtistResource;
use App\Models\Artist;
use App\Models\Organisation;
use App\Services\Artist\ArtistService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class ArtistController extends Controller
{
public function __construct(
private readonly ArtistService $service,
) {}
public function index(Request $request, Organisation $organisation): AnonymousResourceCollection
{
Gate::authorize('viewAny', [Artist::class, $organisation]);
$query = Artist::query()
->where('organisation_id', $organisation->id)
->with(['defaultGenre', 'agentCompany']);
if ($request->boolean('with_trashed')) {
$query->withTrashed();
}
if ($request->boolean('trashed_only')) {
$query->onlyTrashed();
}
if ($request->filled('search')) {
$term = '%'.$request->string('search').'%';
$query->where(function ($q) use ($term): void {
$q->where('name', 'like', $term)->orWhere('slug', 'like', $term);
});
}
if ($request->filled('genre_id')) {
$query->where('default_genre_id', $request->string('genre_id'));
}
if ($request->filled('agent_company_id')) {
$query->where('agent_company_id', $request->string('agent_company_id'));
}
return ArtistResource::collection($query->orderBy('name')->paginate(50));
}
public function show(Organisation $organisation, Artist $artist): JsonResponse
{
Gate::authorize('view', $artist);
$artist->loadMissing(['defaultGenre', 'agentCompany', 'contacts']);
return $this->success(ArtistResource::make($artist));
}
public function store(CreateArtistRequest $request, Organisation $organisation): JsonResponse
{
Gate::authorize('create', [Artist::class, $organisation]);
try {
$artist = $this->service->create($organisation, $request->validated());
} catch (DuplicateArtistException $e) {
return $this->error('Duplicate artist name.', 409, [
'duplicate_artist_id' => $e->existing->id,
]);
}
return $this->created(ArtistResource::make($artist->load(['defaultGenre', 'agentCompany'])));
}
public function update(UpdateArtistRequest $request, Organisation $organisation, Artist $artist): JsonResponse
{
Gate::authorize('update', $artist);
$artist = $this->service->update($artist, $request->validated());
return $this->success(ArtistResource::make($artist->load(['defaultGenre', 'agentCompany'])));
}
public function destroy(Organisation $organisation, Artist $artist): JsonResponse
{
if (! Gate::check('delete', $artist)) {
return $this->forbidden('Cannot delete artist with active engagements.');
}
$this->service->softDelete($artist);
return response()->json(null, 204);
}
public function restore(Organisation $organisation, string $artist): JsonResponse
{
$model = Artist::withTrashed()->findOrFail($artist);
Gate::authorize('restore', $model);
$this->service->restore($model);
return $this->success(ArtistResource::make($model->fresh()));
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Artist;
use App\Exceptions\Artist\InvalidStatusTransitionException;
use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Artist\CreateArtistEngagementRequest;
use App\Http\Requests\Api\V1\Artist\UpdateArtistEngagementRequest;
use App\Http\Resources\Api\V1\Artist\ArtistEngagementResource;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Services\Artist\ArtistEngagementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class ArtistEngagementController extends Controller
{
use VerifiesOrganisationEvent;
public function __construct(
private readonly ArtistEngagementService $service,
) {}
public function index(Request $request, Organisation $organisation, Event $event): AnonymousResourceCollection
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('viewAny', [ArtistEngagement::class, $event]);
$query = ArtistEngagement::query()
->where('event_id', $event->id)
->with(['artist.defaultGenre', 'projectLeader']);
if ($request->filled('status')) {
$query->where('booking_status', $request->string('status'));
}
if ($request->filled('search')) {
$term = '%'.$request->string('search').'%';
$query->whereHas('artist', fn ($q) => $q->where('name', 'like', $term));
}
return ArtistEngagementResource::collection(
$query->orderBy('created_at', 'desc')->paginate(50),
);
}
public function show(Organisation $organisation, Event $event, ArtistEngagement $engagement): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('view', [$engagement, $event]);
$engagement->loadMissing([
'artist.defaultGenre', 'artist.agentCompany', 'artist.contacts',
'projectLeader', 'performances.stage',
]);
return $this->success(ArtistEngagementResource::make($engagement));
}
public function store(CreateArtistEngagementRequest $request, Organisation $organisation, Event $event): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('create', [ArtistEngagement::class, $event]);
$data = $request->validated();
$artist = Artist::query()->findOrFail($data['artist_id']);
try {
$engagement = $this->service->create($event, $artist, $data);
} catch (InvalidStatusTransitionException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->created(
ArtistEngagementResource::make($engagement->load(['artist.defaultGenre', 'projectLeader'])),
);
}
public function update(
UpdateArtistEngagementRequest $request,
Organisation $organisation,
Event $event,
ArtistEngagement $engagement,
): JsonResponse {
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('update', [$engagement, $event]);
try {
$engagement = $this->service->update($engagement, $request->validated());
} catch (InvalidStatusTransitionException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success(
ArtistEngagementResource::make($engagement->load(['artist.defaultGenre', 'projectLeader'])),
);
}
public function destroy(
Organisation $organisation,
Event $event,
ArtistEngagement $engagement,
): JsonResponse {
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('delete', [$engagement, $event]);
$this->service->softDelete($engagement);
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Artist;
use App\Exceptions\Artist\GenreInUseException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Artist\CreateGenreRequest;
use App\Http\Requests\Api\V1\Artist\UpdateGenreRequest;
use App\Http\Resources\Api\V1\Artist\GenreResource;
use App\Models\Genre;
use App\Models\Organisation;
use App\Services\Artist\GenreService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class GenreController extends Controller
{
public function __construct(
private readonly GenreService $service,
) {}
public function index(Organisation $organisation): AnonymousResourceCollection
{
Gate::authorize('viewAny', [Genre::class, $organisation]);
$genres = Genre::query()
->where('organisation_id', $organisation->id)
->orderBy('sort_order')
->orderBy('name')
->get();
return GenreResource::collection($genres);
}
public function store(CreateGenreRequest $request, Organisation $organisation): JsonResponse
{
Gate::authorize('create', [Genre::class, $organisation]);
$genre = $this->service->create($organisation, $request->validated());
return $this->created(GenreResource::make($genre));
}
public function update(UpdateGenreRequest $request, Organisation $organisation, Genre $genre): JsonResponse
{
Gate::authorize('update', $genre);
$genre = $this->service->update($genre, $request->validated());
return $this->success(GenreResource::make($genre));
}
public function destroy(Organisation $organisation, Genre $genre): JsonResponse
{
Gate::authorize('delete', $genre);
try {
$this->service->delete($genre);
} catch (GenreInUseException $e) {
return $this->error($e->getMessage(), 409, [
'referencing_artists_count' => $e->referencingArtistsCount,
]);
}
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Artist;
use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Artist\CreatePerformanceRequest;
use App\Http\Requests\Api\V1\Artist\UpdatePerformanceRequest;
use App\Http\Resources\Api\V1\Artist\PerformanceResource;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Services\Artist\PerformanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class PerformanceController extends Controller
{
use VerifiesOrganisationEvent;
public function __construct(
private readonly PerformanceService $service,
) {}
public function index(Request $request, Organisation $organisation, Event $event): AnonymousResourceCollection
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('viewAny', [Performance::class, $event]);
$query = Performance::query()
->whereHas('engagement', fn ($q) => $q->where('event_id', $event->id))
->with(['engagement.artist.defaultGenre', 'stage']);
if ($request->filled('day')) {
$query->where('event_id', $request->string('day'));
}
if ($request->query('stage_id') === 'null') {
$query->whereNull('stage_id');
} elseif ($request->filled('stage_id')) {
$query->where('stage_id', $request->string('stage_id'));
}
return PerformanceResource::collection($query->orderBy('start_at')->get());
}
public function show(Organisation $organisation, Event $event, Performance $performance): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('view', [$performance, $event]);
$performance->loadMissing(['engagement.artist.defaultGenre', 'stage']);
return $this->success(PerformanceResource::make($performance));
}
public function store(CreatePerformanceRequest $request, Organisation $organisation, Event $event): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('create', [Performance::class, $event]);
$data = $request->validated();
$engagement = ArtistEngagement::query()->findOrFail($data['engagement_id']);
$performance = $this->service->create($engagement, $data);
return $this->created(
PerformanceResource::make($performance->load(['engagement.artist.defaultGenre', 'stage'])),
);
}
public function update(
UpdatePerformanceRequest $request,
Organisation $organisation,
Event $event,
Performance $performance,
): JsonResponse {
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('update', [$performance, $event]);
$performance = $this->service->update($performance, $request->validated());
return $this->success(PerformanceResource::make($performance));
}
public function destroy(
Organisation $organisation,
Event $event,
Performance $performance,
): JsonResponse {
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('delete', [$performance, $event]);
$this->service->delete($performance);
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Artist;
use App\Exceptions\Artist\StageDaysOrphanedPerformancesException;
use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Artist\CreateStageRequest;
use App\Http\Requests\Api\V1\Artist\ReorderStagesRequest;
use App\Http\Requests\Api\V1\Artist\ReplaceStageDaysRequest;
use App\Http\Requests\Api\V1\Artist\UpdateStageRequest;
use App\Http\Resources\Api\V1\Artist\StageResource;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Stage;
use App\Services\Artist\StageDayService;
use App\Services\Artist\StageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class StageController extends Controller
{
use VerifiesOrganisationEvent;
public function __construct(
private readonly StageService $stageService,
private readonly StageDayService $stageDayService,
) {}
public function index(Organisation $organisation, Event $event): AnonymousResourceCollection
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('viewAny', [Stage::class, $event]);
$stages = Stage::query()
->where('event_id', $event->id)
->with('stageDays')
->ordered()
->get();
return StageResource::collection($stages);
}
public function show(Organisation $organisation, Event $event, Stage $stage): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('view', [$stage, $event]);
$stage->loadMissing('stageDays');
return $this->success(StageResource::make($stage));
}
public function store(CreateStageRequest $request, Organisation $organisation, Event $event): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('create', [Stage::class, $event]);
$stage = $this->stageService->create($event, $request->validated());
return $this->created(StageResource::make($stage));
}
public function update(UpdateStageRequest $request, Organisation $organisation, Event $event, Stage $stage): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('update', [$stage, $event]);
$stage = $this->stageService->update($stage, $request->validated());
return $this->success(StageResource::make($stage));
}
public function destroy(Organisation $organisation, Event $event, Stage $stage): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('delete', [$stage, $event]);
$parkedCount = $this->stageService->delete($stage);
return response()->json(['parked_performances' => $parkedCount], 200);
}
public function reorder(ReorderStagesRequest $request, Organisation $organisation, Event $event): JsonResponse
{
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('reorder', [Stage::class, $event]);
$this->stageService->reorder($event, $request->validated('stage_ids'));
$stages = Stage::query()->where('event_id', $event->id)->ordered()->get();
return $this->success(StageResource::collection($stages));
}
public function replaceDays(
ReplaceStageDaysRequest $request,
Organisation $organisation,
Event $event,
Stage $stage,
): JsonResponse {
$this->verifyEventBelongsToOrganisation($organisation, $event);
Gate::authorize('update', [$stage, $event]);
$forceOrphan = $request->boolean('force_orphan')
|| $request->query('force_orphan') === 'true';
try {
$diff = $this->stageDayService->replaceDays(
$stage,
$request->validated('event_ids'),
$forceOrphan,
);
} catch (StageDaysOrphanedPerformancesException $e) {
return $this->error('Removing day(s) would orphan scheduled performances.', 409, [
'conflict' => 'orphaned_performances',
'performances_on_removed_events' => $e->performanceIds,
'removed_event_ids' => $e->removedEventIds,
]);
}
return $this->success([
'stage' => StageResource::make($stage->fresh()->load('stageDays')),
'added_event_ids' => $diff['added'],
'removed_event_ids' => $diff['removed'],
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Artist;
use App\Exceptions\Artist\VersionMismatchException;
use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Artist\MoveTimetablePerformanceRequest;
use App\Http\Resources\Api\V1\Artist\PerformanceResource;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Services\Artist\LaneCascadeService;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class TimetableMoveController extends Controller
{
use VerifiesOrganisationEvent;
public function __construct(
private readonly LaneCascadeService $service,
) {}
public function __invoke(
MoveTimetablePerformanceRequest $request,
Organisation $organisation,
Event $event,
): JsonResponse {
$this->verifyEventBelongsToOrganisation($organisation, $event);
$data = $request->validated();
$performance = Performance::query()->findOrFail($data['performance_id']);
Gate::authorize('move', [$performance, $event]);
$targetStage = isset($data['target_stage_id'])
? Stage::query()->find($data['target_stage_id'])
: null;
$start = isset($data['target_start_at'])
? CarbonImmutable::parse((string) $data['target_start_at'])
: null;
$end = isset($data['target_end_at'])
? CarbonImmutable::parse((string) $data['target_end_at'])
: null;
try {
$result = $this->service->move(
performance: $performance,
targetStage: $targetStage,
start: $start,
end: $end,
targetLane: isset($data['target_lane']) ? (int) $data['target_lane'] : null,
clientVersion: (int) $data['version'],
);
} catch (VersionMismatchException $e) {
$performance->refresh();
return $this->error('Version mismatch — performance was modified by another request.', 409, [
'conflict' => 'version_mismatch',
'current_version' => $e->currentVersion,
'client_version' => $e->clientVersion,
'server_data' => PerformanceResource::make(
$performance->load(['engagement.artist.defaultGenre', 'stage']),
)->toArray(request()),
]);
}
return $this->success([
'moved' => PerformanceResource::make(
$result->moved->load(['engagement.artist.defaultGenre', 'stage']),
),
'cascaded' => PerformanceResource::collection(
collect($result->cascaded)->each->load(['engagement.artist.defaultGenre', 'stage']),
),
]);
}
}

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Auth;
use App\Enums\MfaMethod;
use App\Http\Controllers\Api\V1\Traits\SetAuthCookie;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Auth\MfaEmailSendRequest;
use App\Http\Requests\Api\V1\Auth\MfaVerifyRequest;
use App\Http\Resources\Api\V1\MeResource;
use App\Enums\MfaMethod;
use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
@@ -58,19 +58,18 @@ final class MfaVerifyController extends Controller
]);
$token = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => new MeResource($user),
], 'MFA verification successful')
->withCookie($this->makeAuthCookie($cookieName, $token));
->withCookie($this->makeAuthCookie($token));
}
public function sendEmailCode(MfaEmailSendRequest $request): JsonResponse
{
$sessionToken = $request->validated('mfa_session_token');
$cacheKey = 'mfa_session:' . $sessionToken;
$cacheKey = 'mfa_session:'.$sessionToken;
$session = Cache::get($cacheKey);
if (! $session) {

View File

@@ -24,7 +24,6 @@ final class AuthRefreshController extends Controller
// Create a new token
$newToken = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
$user->load(['organisations', 'roles', 'permissions']);
@@ -34,6 +33,6 @@ final class AuthRefreshController extends Controller
]);
return $this->success(new MeResource($user), 'Token refreshed')
->withCookie($this->makeAuthCookie($cookieName, $newToken));
->withCookie($this->makeAuthCookie($newToken));
}
}

View File

@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\Gate;
final class InvitationController extends Controller
{
use SetAuthCookie;
public function __construct(
private readonly InvitationService $invitationService,
) {}
@@ -65,7 +66,6 @@ final class InvitationController extends Controller
);
$sanctumToken = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => [
@@ -76,7 +76,7 @@ final class InvitationController extends Controller
'email' => $user->email,
],
], 'Uitnodiging geaccepteerd')
->withCookie($this->makeAuthCookie($cookieName, $sanctumToken));
->withCookie($this->makeAuthCookie($sanctumToken));
}
public function revoke(Organisation $organisation, UserInvitation $invitation): JsonResponse

View File

@@ -65,13 +65,11 @@ final class LoginController extends Controller
// Return MFA challenge — NO auth token, NO auth cookie.
// Expire the auth cookie to invalidate any stale browser session.
$cookieName = $this->resolveCookieName($request);
return response()->json([
'success' => true,
'mfa_required' => true,
...$mfaSession,
])->withCookie($this->forgetAuthCookie($cookieName));
])->withCookie($this->forgetAuthCookie());
}
// MFA required by policy but not yet set up — issue token with flag
@@ -80,11 +78,10 @@ final class LoginController extends Controller
$data = $response->getData(true);
$data['mfa_setup_required'] = true;
$cookieName = $this->resolveCookieName($request);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json($data)
->withCookie($this->makeAuthCookie($cookieName, $token));
->withCookie($this->makeAuthCookie($token));
}
// No MFA — issue token as normal
@@ -101,11 +98,10 @@ final class LoginController extends Controller
]);
$token = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => new MeResource($user),
], 'Login successful')
->withCookie($this->makeAuthCookie($cookieName, $token));
->withCookie($this->makeAuthCookie($token));
}
}

View File

@@ -17,9 +17,7 @@ final class LogoutController extends Controller
{
$request->user()->currentAccessToken()->delete();
$cookieName = $this->resolveCookieName($request);
return $this->success(null, 'Logged out successfully')
->withCookie($this->forgetAuthCookie($cookieName));
->withCookie($this->forgetAuthCookie());
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Portal;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Exceptions\Artist\ArtistDeletedException;
use App\Exceptions\Artist\InvalidPortalTokenException;
use App\FormBuilder\Resolvers\ArtistResolver;
use App\FormBuilder\Resolvers\ArtistResolverResult;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Portal\SubmitEngagementSectionRequest;
use App\Http\Resources\Api\V1\Portal\EngagementPortalResource;
use App\Models\AdvanceSection;
use App\Models\ArtistEngagement;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionSectionStatus;
use App\Models\FormBuilder\FormValue;
use App\Models\Scopes\OrganisationScope;
use App\Services\FormBuilder\FormSubmissionService;
use App\Services\FormBuilder\FormValueService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Public artist-advance portal endpoints. Mounted under /p/artist/{token}/...
*
* GET /p/artist/{token} engagement summary + sections
* GET /p/artist/{token}/sections/{section} schema + draft values for one section
* POST /p/artist/{token}/sections/{section} submit one section
*
* The route token is the plaintext portal token; resolution happens
* via ArtistResolver::fromPortalToken (SHA-256 digest match against
* artist_engagements.portal_token, Session 1 commit eb6d396). Master
* Artist is the FormSubmission subject; engagement.event_id populates
* form_submissions.event_id per WS-4 denormalisation.
*
* The standard FormBindingApplicator pipeline (RFC-WS-6 v1.3.1) runs
* via the FormSubmissionSectionSubmitted listener this controller
* does not duplicate any binding-apply logic.
*
* AdvanceSection (engagement-scoped) FormSchemaSection bridge:
* matched by name (case-sensitive) on the organisation's artist_advance
* schema. The default seeder names them in lockstep.
*/
final class EngagementPortalController extends Controller
{
public function __construct(
private readonly ArtistResolver $artistResolver,
private readonly FormSubmissionService $submissionService,
private readonly FormValueService $valueService,
) {}
public function show(string $token): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$engagement = $resolved->engagement->load([
'artist' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
'event' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
'advanceSections' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class)->orderBy('sort_order'),
]);
return $this->success(new EngagementPortalResource($engagement));
}
public function showSection(string $token, string $section): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$advanceSection = $this->findAdvanceSection($resolved->engagement, $section);
if ($advanceSection === null) {
return $this->error('Section not found on this engagement.', 404);
}
$schema = $this->resolveAdvanceSchema($resolved);
if ($schema === null) {
return $this->error('Artist advance schema not configured for this organisation.', 404);
}
$schemaSection = $this->findSchemaSectionFor($schema, $advanceSection);
if ($schemaSection === null) {
return $this->error('Section is not mapped to a form schema section.', 404);
}
$fields = FormField::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_section_id', $schemaSection->id)
->orderBy('sort_order')
->get();
$submission = $this->findExistingDraft($schema, $resolved->engagement);
$existingValues = $submission instanceof FormSubmission
? FormValue::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_submission_id', $submission->id)
->whereIn('form_field_id', $fields->pluck('id')->all())
->get()
->keyBy(fn (FormValue $v) => (string) $v->form_field_id)
: collect();
return $this->success([
'section' => [
'id' => (string) $advanceSection->id,
'name' => (string) $advanceSection->name,
'type' => $advanceSection->getRawOriginal('type'),
'submission_status' => $advanceSection->getRawOriginal('submission_status'),
'is_open' => (bool) $advanceSection->is_open,
],
'fields' => $fields->map(static fn (FormField $field): array => [
'id' => (string) $field->id,
'slug' => (string) $field->slug,
'label' => (string) $field->label,
'help_text' => $field->help_text,
'field_type' => (string) $field->field_type,
'is_required' => (bool) $field->is_required,
'display_width' => $field->getRawOriginal('display_width'),
'sort_order' => (int) $field->sort_order,
])->all(),
'values' => $fields->mapWithKeys(static function (FormField $field) use ($existingValues): array {
$value = $existingValues->get((string) $field->id);
return [(string) $field->slug => $value?->value];
})->all(),
]);
}
public function submitSection(SubmitEngagementSectionRequest $request, string $token, string $section): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$advanceSection = $this->findAdvanceSection($resolved->engagement, $section);
if ($advanceSection === null) {
return $this->error('Section not found on this engagement.', 404);
}
$schema = $this->resolveAdvanceSchema($resolved);
if ($schema === null) {
return $this->error('Artist advance schema not configured for this organisation.', 404);
}
$schemaSection = $this->findSchemaSectionFor($schema, $advanceSection);
if ($schemaSection === null) {
return $this->error('Section is not mapped to a form schema section.', 404);
}
/** @var array<string, mixed> $values */
$values = (array) $request->validated('values', []);
$result = DB::transaction(function () use ($resolved, $schema, $schemaSection, $advanceSection, $values): array {
$submission = $this->findOrCreateDraft($schema, $resolved);
$this->valueService->upsertMany($submission, $values, null);
$sectionStatus = FormSubmissionSectionStatus::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_submission_id', $submission->id)
->where('form_schema_section_id', $schemaSection->id)
->first();
if ($sectionStatus === null) {
$sectionStatus = new FormSubmissionSectionStatus;
$sectionStatus->form_submission_id = $submission->id;
$sectionStatus->form_schema_section_id = $schemaSection->id;
}
$sectionStatus->status = 'submitted';
$sectionStatus->submitted_at = now();
$sectionStatus->save();
$advanceSection->submission_status = AdvanceSectionSubmissionStatus::Submitted->value;
$advanceSection->last_submitted_at = now()->toDateTimeString();
$advanceSection->save();
return [$submission->refresh(), $sectionStatus->refresh(), $advanceSection->refresh()];
});
[$submission, $sectionStatus] = $result;
\App\Events\FormBuilder\FormSubmissionSectionSubmitted::dispatch($submission, $sectionStatus);
return $this->success([
'submission_id' => (string) $submission->id,
'section_status' => (string) $sectionStatus->status,
'advance_section_status' => AdvanceSectionSubmissionStatus::Submitted->value,
]);
}
private function findAdvanceSection(ArtistEngagement $engagement, string $sectionId): ?AdvanceSection
{
return AdvanceSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('engagement_id', $engagement->id)
->whereKey($sectionId)
->first();
}
private function resolveAdvanceSchema(ArtistResolverResult $resolved): ?FormSchema
{
return FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $resolved->engagement->organisation_id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->first();
}
private function findSchemaSectionFor(FormSchema $schema, AdvanceSection $advanceSection): ?FormSchemaSection
{
return FormSchemaSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('name', $advanceSection->name)
->first();
}
private function findExistingDraft(FormSchema $schema, ArtistEngagement $engagement): ?FormSubmission
{
return FormSubmission::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('subject_type', 'artist')
->where('subject_id', $engagement->artist_id)
->where('event_id', $engagement->event_id)
->orderBy('created_at')
->first();
}
private function findOrCreateDraft(FormSchema $schema, ArtistResolverResult $resolved): FormSubmission
{
$existing = $this->findExistingDraft($schema, $resolved->engagement);
if ($existing instanceof FormSubmission) {
return $existing;
}
// Pass event_id via the context bag — the schema is org-owned (not
// event-owned) and this route has no {event} parameter for the
// FormSubmissionObserver fallback. ARCH-FORM-BUILDER §17.3 footnote.
// idempotency_key column is varchar(30); 'aa:' + 26-char ULID fits.
return $this->submissionService->createDraft(
schema: $schema,
subject: $resolved->subject,
submitter: null,
context: [
'idempotency_key' => 'aa:'.$resolved->engagement->id,
'event_id' => $resolved->eventId,
],
);
}
}

View File

@@ -18,18 +18,28 @@ final class PortalTokenController extends Controller
{
$hashedToken = hash('sha256', $request->validated('token'));
// Try artists table
$artist = DB::table('artists')->where('portal_token', $hashedToken)->first();
// Artist portal token lives on artist_engagements (per RFC-TIMETABLE
// v0.2 §5.3); join to artists for the master name.
$row = DB::table('artist_engagements')
->join('artists', 'artists.id', '=', 'artist_engagements.artist_id')
->where('artist_engagements.portal_token', $hashedToken)
->select(
'artist_engagements.id as id',
'artist_engagements.event_id as event_id',
'artist_engagements.booking_status as booking_status',
'artists.name as name',
)
->first();
if ($artist) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($artist->event_id);
if ($row) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($row->event_id);
return response()->json([
'context' => 'artist',
'data' => [
'id' => $artist->id,
'name' => $artist->name,
'booking_status' => $artist->booking_status,
'id' => $row->id,
'name' => $row->name,
'booking_status' => $row->booking_status,
],
'event' => $event ? new PortalEventResource($event) : null,
]);

View File

@@ -4,42 +4,18 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Traits;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie;
trait SetAuthCookie
{
private const COOKIE_MAP = [
'app' => 'crewli_app_token',
'portal' => 'crewli_portal_token',
];
private const COOKIE_NAME = 'crewli_app_token';
private const COOKIE_TTL_MINUTES = 60 * 24 * 7; // 7 days
protected function resolveCookieName(Request $request): string
{
$origin = $request->headers->get('Origin')
?? $request->headers->get('Referer')
?? '';
$appUrl = config('app.frontend_app_url', 'http://localhost:5174');
$portalUrl = config('app.frontend_portal_url', 'http://localhost:5175');
if ($this->originMatches($origin, $appUrl)) {
return self::COOKIE_MAP['app'];
}
if ($this->originMatches($origin, $portalUrl)) {
return self::COOKIE_MAP['portal'];
}
return self::COOKIE_MAP['app'];
}
protected function makeAuthCookie(string $cookieName, string $token): Cookie
protected function makeAuthCookie(string $token): Cookie
{
return new Cookie(
name: $cookieName,
name: self::COOKIE_NAME,
value: $token,
expire: now()->addMinutes(self::COOKIE_TTL_MINUTES),
path: '/',
@@ -50,10 +26,10 @@ trait SetAuthCookie
);
}
protected function forgetAuthCookie(string $cookieName): Cookie
protected function forgetAuthCookie(): Cookie
{
return new Cookie(
name: $cookieName,
name: self::COOKIE_NAME,
value: '',
expire: now()->subMinute(),
path: '/',
@@ -63,19 +39,4 @@ trait SetAuthCookie
sameSite: 'Strict',
);
}
private function originMatches(string $origin, string $configuredUrl): bool
{
if ($origin === '' || $configuredUrl === '') {
return false;
}
// Parse to compare host+port, ignoring trailing slashes and paths
$originHost = parse_url($origin, PHP_URL_HOST);
$originPort = parse_url($origin, PHP_URL_PORT);
$configHost = parse_url($configuredUrl, PHP_URL_HOST);
$configPort = parse_url($configuredUrl, PHP_URL_PORT);
return $originHost === $configHost && $originPort === $configPort;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Organisation;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Structured-logging context binder (RFC-WS-7 §3.13). Tags every Laravel
* log line written during this request with request_id, organisation_id,
* user_id, and route name. Round-trips X-Request-Id with the response so
* the SPA can correlate to backend log lines via one click.
*/
final class BindRequestLogContext
{
public function handle(Request $request, Closure $next): Response
{
$requestId = $this->resolveRequestId($request);
$request->attributes->set('observability.request_id', $requestId);
Log::withContext(array_filter([
'request_id' => $requestId,
'organisation_id' => $this->resolveOrganisationId($request),
'user_id' => $request->user()?->getAuthIdentifier(),
'route' => $request->route()?->getName(),
], static fn ($v) => $v !== null && $v !== ''));
$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);
return $response;
}
private function resolveRequestId(Request $request): string
{
$supplied = $request->header('X-Request-Id');
if (is_string($supplied) && Str::isUlid($supplied)) {
return $supplied;
}
return (string) Str::ulid();
}
private function resolveOrganisationId(Request $request): ?string
{
$portalEvent = $request->attributes->get('portal_event');
if ($portalEvent instanceof Event) {
return $portalEvent->organisation_id;
}
$route = $request->route();
if ($route === null) {
return null;
}
$org = $route->parameter('organisation');
if ($org instanceof Organisation) {
return $org->id;
}
if (is_string($org) && $org !== '') {
return $org;
}
$event = $route->parameter('event');
if ($event instanceof Event) {
return $event->organisation_id;
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Sentry\State\Scope;
use Symfony\Component\HttpFoundation\Response;
use function Sentry\configureScope;
/**
* Binds route-scope context to Sentry events on every API request.
*
* Auth-scope tags (user_id, actor_type, organisation_id, impersonation.*,
* actor_scope) live in {@see \App\Listeners\Observability\AuthScopeContextListener}
* so they bind on Authenticated event rather than route entry. That keeps
* the auth-scope binding uniform across Sanctum, portal-tokens, and any
* future authenticator without per-route middleware-attachment.
*
* RFC-WS-7 §3.6.
*/
final class BindSentryRouteContext
{
public function handle(Request $request, Closure $next): Response
{
configureScope(static function (Scope $scope) use ($request): void {
$scope->setTag('app', 'api');
$scope->setTag('http.method', $request->method());
$routeName = $request->route()?->getName();
if (is_string($routeName) && $routeName !== '') {
$scope->setTag('route_name', $routeName);
}
});
return $next($request);
}
}

View File

@@ -10,74 +10,21 @@ use Symfony\Component\HttpFoundation\Response;
final class CookieBearerToken
{
private const COOKIE_NAMES = [
'crewli_app_token',
'crewli_portal_token',
];
private const COOKIE_NAME = 'crewli_app_token';
public function handle(Request $request, Closure $next): Response
{
// Skip if an Authorization header is already present
// Skip if an Authorization header is already present (e.g. portal-token
// Bearer flow for artists/suppliers, or server-to-server callers).
if ($request->hasHeader('Authorization')) {
return $next($request);
}
// Resolve the cookie name for the requesting app via Origin header.
// This prevents cross-app cookie leakage on localhost where the
// browser sends all cookies regardless of port.
$cookieName = $this->resolveCookieName($request);
if ($cookieName) {
$token = $request->cookie($cookieName);
if ($token) {
$request->headers->set('Authorization', 'Bearer ' . $token);
}
$token = $request->cookie(self::COOKIE_NAME);
if ($token) {
$request->headers->set('Authorization', 'Bearer '.$token);
}
return $next($request);
}
private function resolveCookieName(Request $request): ?string
{
$origin = $request->headers->get('Origin')
?? $request->headers->get('Referer')
?? '';
if ($origin === '') {
// No Origin — fall back to first available cookie (e.g. server-to-server)
foreach (self::COOKIE_NAMES as $name) {
if ($request->cookie($name)) {
return $name;
}
}
return null;
}
$originHost = parse_url($origin, PHP_URL_HOST);
$originPort = parse_url($origin, PHP_URL_PORT);
$map = [
'app' => [config('app.frontend_app_url', 'http://localhost:5174'), 'crewli_app_token'],
'portal' => [config('app.frontend_portal_url', 'http://localhost:5175'), 'crewli_portal_token'],
];
foreach ($map as [$configuredUrl, $cookieName]) {
$configHost = parse_url($configuredUrl, PHP_URL_HOST);
$configPort = parse_url($configuredUrl, PHP_URL_PORT);
if ($originHost === $configHost && $originPort === $configPort) {
return $cookieName;
}
}
// Origin didn't match any configured frontend — fall back to first available
foreach (self::COOKIE_NAMES as $name) {
if ($request->cookie($name)) {
return $name;
}
}
return null;
}
}

View File

@@ -4,13 +4,17 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\ImpersonationService;
use App\Enums\Observability\ActorType;
use App\Models\User;
use App\Services\ImpersonationService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Sentry\State\Scope;
use Symfony\Component\HttpFoundation\Response;
use function Sentry\configureScope;
class HandleImpersonation
{
/**
@@ -88,6 +92,24 @@ class HandleImpersonation
'impersonation_session_id' => $session->id,
]);
// Re-bind Sentry auth-scope tags after the user swap. The
// Authenticated event already fired with the admin; AuthScopeContextListener
// tagged the admin's user_id/actor_type. We now overwrite both with
// the target's data and add the impersonation.* invariants
// (RFC-WS-7 §3.6) so captured events attribute correctly.
$targetActorType = ActorType::resolve($targetUser, $request);
configureScope(static function (Scope $scope) use ($admin, $targetUser, $session, $targetActorType): void {
$scope->setUser([
'id' => $targetUser->id,
'username' => $targetUser->id,
]);
$scope->setTag('user_id', $targetUser->id);
$scope->setTag('actor_type', $targetActorType->value);
$scope->setTag('impersonation.active', 'true');
$scope->setTag('impersonation.impersonator_user_id', $admin->id);
$scope->setTag('impersonation.session_id', $session->id);
});
// Increment actions count
$this->impersonationService->incrementActionsCount($session);

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
/**
* RFC v0.2 R1 60-second Idempotency-Key replay window backed by
* Redis cache.
*
* Why 60s and not the 12-hour MySQL window from ARCH §10:
* a stale 12-hour replay of the cascade-bump endpoint can corrupt
* timetable state in hard-to-detect ways (the persisted lanes have
* since been edited; replaying a cached response over a fresh edit
* would silently undo it). 60 seconds covers honest network retry
* without giving stale requests a window in which to resurrect.
*
* Storage: Laravel Cache facade with the default store (Redis in
* non-test environments). The key namespace `idempotency:60s:` is
* deliberately distinct from any other idempotency surface in the
* codebase keys never collide with the FormSubmission DB-column
* idempotency.
*
* Applied today only on `POST /api/v1/events/{event}/timetable/move`.
* Other R-numbered idempotent endpoints (RFC §6 lists POST
* /performances and POST /engagements as candidates) get the regular
* 12-hour pattern when ARCH §10 lands; this middleware is purposely
* narrow.
*/
final class IdempotencyKey60sRedis
{
public function handle(Request $request, Closure $next): Response
{
$key = $request->header('Idempotency-Key');
if (! is_string($key) || trim($key) === '') {
return response()->json(
['error' => 'idempotency_key_required'],
400,
);
}
$cacheKey = 'idempotency:60s:'.$key;
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
$response = response($cached['body'], $cached['status']);
foreach ($cached['headers'] ?? [] as $name => $value) {
$response->headers->set($name, $value);
}
$response->headers->set('Idempotency-Replayed', 'true');
return $response;
}
/** @var Response $response */
$response = $next($request);
if ($response->isSuccessful()) {
Cache::put($cacheKey, [
'status' => $response->getStatusCode(),
'body' => $response->getContent(),
'headers' => [
'Content-Type' => $response->headers->get('Content-Type'),
],
], 60);
}
return $response;
}
}

View File

@@ -23,18 +23,19 @@ final class PortalTokenMiddleware
$hashedToken = hash('sha256', $plainToken);
// Try artists table
$artist = DB::table('artists')->where('portal_token', $hashedToken)->first();
// Artist portal token lives on artist_engagements (per RFC-TIMETABLE
// v0.2 §5.3); resolve to the engagement's event.
$engagement = DB::table('artist_engagements')->where('portal_token', $hashedToken)->first();
if ($artist) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($artist->event_id);
if ($engagement) {
$event = Event::withoutGlobalScope(OrganisationScope::class)->find($engagement->event_id);
if (! $event || in_array($event->status, ['draft', 'closed'], true)) {
return response()->json(['message' => 'Portal token required.'], 401);
}
$request->attributes->set('portal_context', 'artist');
$request->attributes->set('portal_person', $artist);
$request->attributes->set('portal_person', $engagement);
$request->attributes->set('portal_event', $event);
return $next($request);

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Enums\Artist\BumaHandledBy;
use App\Enums\Artist\FeeType;
use App\Enums\Artist\PaymentStatus;
use App\Models\Event;
use App\Rules\Artist\ContractRequiresFee;
use App\Rules\Artist\OptionExpiresInFuture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.3.
*/
final class CreateArtistEngagementRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$event = $this->route('event');
$organisationId = $event instanceof Event ? $event->organisation_id : null;
$bookingStatus = $this->input('booking_status');
return [
'artist_id' => [
'required', 'string', 'max:30',
Rule::exists('artists', 'id')->where('organisation_id', $organisationId),
],
'booking_status' => ['required', Rule::enum(ArtistEngagementStatus::class)],
'project_leader_id' => ['nullable', 'string', 'max:30', 'exists:users,id'],
'fee_amount' => ['nullable', 'numeric', 'min:0', 'max:9999999.99', new ContractRequiresFee($bookingStatus)],
'fee_currency' => ['nullable', 'string', 'size:3', Rule::in(['EUR', 'USD', 'GBP'])],
'fee_type' => ['nullable', Rule::enum(FeeType::class)],
'buma_applicable' => ['nullable', 'boolean'],
'buma_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'buma_handled_by' => ['nullable', Rule::enum(BumaHandledBy::class)],
'vat_applicable' => ['nullable', 'boolean'],
'vat_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'deal_breakdown' => ['nullable', 'array'],
'deposit_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'deposit_due_date' => ['nullable', 'date'],
'balance_due_date' => ['nullable', 'date'],
'payment_status' => ['nullable', Rule::enum(PaymentStatus::class)],
'crew_count' => ['nullable', 'integer', 'min:0', 'max:200'],
'guests_count' => ['nullable', 'integer', 'min:0', 'max:1000'],
'requested_at' => ['nullable', 'date'],
'option_expires_at' => ['nullable', 'date', new OptionExpiresInFuture($bookingStatus)],
'advance_open_from' => ['nullable', 'date'],
'advance_open_to' => ['nullable', 'date', 'after_or_equal:advance_open_from'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Organisation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §5.3 (artists table) + §10.3 derived shape.
*
* Authorization is handled in the controller via Gate::authorize per
* the codebase convention; this request returns true.
*/
final class CreateArtistRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$organisationId = $this->route('organisation') instanceof Organisation
? $this->route('organisation')->id
: (string) $this->route('organisation');
return [
'name' => ['required', 'string', 'max:120'],
'default_genre_id' => [
'nullable', 'string', 'max:30',
Rule::exists('genres', 'id')->where('organisation_id', $organisationId),
],
'default_draw' => ['nullable', 'integer', 'min:0'],
'star_rating' => ['nullable', 'integer', 'between:1,5'],
'home_base_country' => ['nullable', 'string', 'size:2', 'alpha'],
'agent_company_id' => [
'nullable', 'string', 'max:30',
Rule::exists('companies', 'id')->where('organisation_id', $organisationId),
],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Organisation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class CreateGenreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$organisationId = $this->route('organisation') instanceof Organisation
? $this->route('organisation')->id
: (string) $this->route('organisation');
return [
'name' => [
'required', 'string', 'max:40',
Rule::unique('genres', 'name')->where('organisation_id', $organisationId),
],
'color' => ['nullable', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'is_active' => ['nullable', 'boolean'],
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Rules\Artist\StageActiveOnEvent;
use App\Rules\Artist\WithinEventBounds;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.2.
*/
final class CreatePerformanceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$event = $this->route('event');
$organisationId = $event instanceof Event ? $event->organisation_id : null;
$eventIdInput = (string) $this->input('event_id', '');
return [
'engagement_id' => [
'required', 'string', 'max:30',
Rule::exists('artist_engagements', 'id')->where('organisation_id', $organisationId),
],
'event_id' => [
'required', 'string', 'max:30',
Rule::exists('events', 'id')->where('organisation_id', $organisationId),
],
'stage_id' => [
'nullable', 'string', 'max:30',
Rule::exists('stages', 'id'),
new StageActiveOnEvent($eventIdInput),
],
'start_at' => ['required', 'date_format:Y-m-d H:i:s', new WithinEventBounds($eventIdInput)],
'end_at' => ['required', 'date_format:Y-m-d H:i:s', 'after:start_at', new WithinEventBounds($eventIdInput)],
'lane' => ['nullable', 'integer', 'min:0', 'max:9'],
'notes' => ['nullable', 'string', 'max:1000'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$engagementId = $this->input('engagement_id');
$eventId = $this->input('event_id');
if (! is_string($engagementId) || ! is_string($eventId)) {
return;
}
$engagement = ArtistEngagement::query()->find($engagementId);
if ($engagement === null) {
return;
}
$event = Event::withoutGlobalScopes()->find($eventId);
if ($event === null) {
return;
}
// event_id must equal engagement.event_id (flat case) OR be a
// sub-event of engagement.event_id (festival case).
if (
$eventId !== $engagement->event_id
&& $event->parent_event_id !== $engagement->event_id
) {
$validator->errors()->add(
'event_id',
'event_id moet gelijk zijn aan de engagement.event_id of een sub-event daarvan.',
);
}
});
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class CreateStageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$eventId = $this->route('event') instanceof Event
? $this->route('event')->id
: (string) $this->route('event');
return [
'name' => [
'required', 'string', 'max:120',
Rule::unique('stages', 'name')->where('event_id', $eventId),
],
'color' => ['required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'capacity' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['nullable', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Stage;
use App\Models\StageDay;
use App\Rules\Artist\StageActiveOnEvent;
use App\Rules\Artist\WithinEventBounds;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.4 D18 transactional move endpoint.
*/
final class MoveTimetablePerformanceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$event = $this->route('event');
$resolvedEventId = $this->resolveTargetEventId();
return [
// performances has no organisation_id column (FK-chain via
// engagement_id); cross-tenant is caught by the policy in
// TimetableMoveController via Gate::authorize('move', ...).
'performance_id' => [
'required', 'string', 'max:30',
Rule::exists('performances', 'id'),
],
'target_stage_id' => [
'nullable', 'string', 'max:30',
Rule::exists('stages', 'id'),
new StageActiveOnEvent($resolvedEventId),
],
'target_start_at' => [
'nullable', 'date_format:Y-m-d H:i:s',
'required_unless:target_stage_id,null',
new WithinEventBounds($resolvedEventId),
],
'target_end_at' => [
'nullable', 'date_format:Y-m-d H:i:s',
'required_unless:target_stage_id,null',
'after:target_start_at',
new WithinEventBounds($resolvedEventId),
],
'target_lane' => ['nullable', 'integer', 'min:0', 'max:9'],
'version' => ['required', 'integer', 'min:0'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
// When target_stage_id is non-null, target_lane must be set
// (the move algorithm requires a definite lane).
if ($this->input('target_stage_id') !== null && $this->input('target_lane') === null) {
$validator->errors()->add('target_lane', 'target_lane is verplicht bij een niet-leeg target_stage_id.');
}
});
}
/**
* Resolve the event_id the candidate move lands on so the
* StageActiveOnEvent and WithinEventBounds rules can validate
* against a concrete event window.
*
* For flat events: stage.event_id is the answer.
* For festivals: walk stage_days for target_stage_id and find the
* sub-event whose [start, end] contains target_start_at.
*/
private function resolveTargetEventId(): ?string
{
$stageId = $this->input('target_stage_id');
$startAt = $this->input('target_start_at');
if (! is_string($stageId) || ! is_string($startAt)) {
return null;
}
$start = CarbonImmutable::parse($startAt);
$stage = Stage::query()->find($stageId);
if ($stage === null) {
return null;
}
$match = StageDay::query()
->where('stage_id', $stage->id)
->join('events', 'events.id', '=', 'stage_days.event_id')
->where('events.start_date', '<=', $start->toDateString())
->where('events.end_date', '>=', $start->toDateString())
->orderBy('events.start_date', 'desc')
->limit(1)
->value('stage_days.event_id');
return is_string($match) ? $match : $stage->event_id;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use App\Models\Stage;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
final class ReorderStagesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'stage_ids' => ['required', 'array', 'min:1'],
'stage_ids.*' => ['string', 'max:30'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$event = $this->route('event');
if (! $event instanceof Event) {
return;
}
$submitted = (array) $this->input('stage_ids', []);
$existing = Stage::query()
->where('event_id', $event->id)
->pluck('id')
->all();
$missing = array_diff($existing, $submitted);
$extra = array_diff($submitted, $existing);
if ($missing !== [] || $extra !== []) {
$validator->errors()->add(
'stage_ids',
'stage_ids moet een permutatie zijn van alle stages op dit evenement.',
);
}
});
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use App\Models\Stage;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* RFC v0.2 §10.5 atomic stage_days matrix replace.
*/
final class ReplaceStageDaysRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$event = $this->route('event');
$organisationId = $event instanceof Event ? $event->organisation_id : null;
return [
'event_ids' => ['required', 'array', 'min:1'],
'event_ids.*' => [
'string', 'max:30',
Rule::exists('events', 'id')->where('organisation_id', $organisationId),
],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$stage = $this->route('stage');
if (! $stage instanceof Stage) {
return;
}
$eventIds = (array) $this->input('event_ids', []);
$events = Event::withoutGlobalScopes()
->whereIn('id', $eventIds)
->get(['id', 'parent_event_id']);
foreach ($events as $event) {
$isFlatMatch = $event->id === $stage->event_id;
$isSubEventMatch = $event->parent_event_id === $stage->event_id;
if (! $isFlatMatch && ! $isSubEventMatch) {
$validator->errors()->add(
'event_ids',
sprintf(
'event_id %s is geen sub-event van of gelijk aan stage.event_id (%s).',
$event->id,
$stage->event_id,
),
);
}
}
});
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Enums\Artist\BumaHandledBy;
use App\Enums\Artist\FeeType;
use App\Enums\Artist\PaymentStatus;
use App\Models\ArtistEngagement;
use App\Rules\Artist\ContractRequiresFee;
use App\Rules\Artist\OptionExpiresInFuture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateArtistEngagementRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$engagement = $this->route('engagement');
$effectiveStatus = $this->input(
'booking_status',
$engagement instanceof ArtistEngagement
? ($engagement->booking_status?->value ?? null)
: null,
);
return [
'booking_status' => ['sometimes', Rule::enum(ArtistEngagementStatus::class)],
'project_leader_id' => ['sometimes', 'nullable', 'string', 'max:30', 'exists:users,id'],
'fee_amount' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:9999999.99', new ContractRequiresFee($effectiveStatus)],
'fee_currency' => ['sometimes', 'nullable', 'string', 'size:3', Rule::in(['EUR', 'USD', 'GBP'])],
'fee_type' => ['sometimes', 'nullable', Rule::enum(FeeType::class)],
'buma_applicable' => ['sometimes', 'boolean'],
'buma_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'buma_handled_by' => ['sometimes', 'nullable', Rule::enum(BumaHandledBy::class)],
'vat_applicable' => ['sometimes', 'boolean'],
'vat_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'deal_breakdown' => ['sometimes', 'nullable', 'array'],
'deposit_percentage' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'deposit_due_date' => ['sometimes', 'nullable', 'date'],
'balance_due_date' => ['sometimes', 'nullable', 'date'],
'payment_status' => ['sometimes', 'nullable', Rule::enum(PaymentStatus::class)],
'crew_count' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:200'],
'guests_count' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:1000'],
'requested_at' => ['sometimes', 'nullable', 'date'],
'option_expires_at' => ['sometimes', 'nullable', 'date', new OptionExpiresInFuture($effectiveStatus)],
'advance_open_from' => ['sometimes', 'nullable', 'date'],
'advance_open_to' => ['sometimes', 'nullable', 'date', 'after_or_equal:advance_open_from'],
'notes' => ['sometimes', 'nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Artist;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateArtistRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$artist = $this->route('artist');
$organisationId = $artist instanceof Artist
? $artist->organisation_id
: ($this->route('organisation')?->id ?? null);
return [
'name' => ['sometimes', 'required', 'string', 'max:120'],
'default_genre_id' => [
'sometimes', 'nullable', 'string', 'max:30',
Rule::exists('genres', 'id')->where('organisation_id', $organisationId),
],
'default_draw' => ['sometimes', 'nullable', 'integer', 'min:0'],
'star_rating' => ['sometimes', 'nullable', 'integer', 'between:1,5'],
'home_base_country' => ['sometimes', 'nullable', 'string', 'size:2', 'alpha'],
'agent_company_id' => [
'sometimes', 'nullable', 'string', 'max:30',
Rule::exists('companies', 'id')->where('organisation_id', $organisationId),
],
'notes' => ['sometimes', 'nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Genre;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateGenreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$genre = $this->route('genre');
$organisationId = $genre instanceof Genre
? $genre->organisation_id
: ($this->route('organisation')?->id ?? null);
$genreId = $genre instanceof Genre ? $genre->id : null;
return [
'name' => [
'sometimes', 'required', 'string', 'max:40',
Rule::unique('genres', 'name')
->where('organisation_id', $organisationId)
->ignore($genreId),
],
'color' => ['sometimes', 'nullable', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'sort_order' => ['sometimes', 'nullable', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use Illuminate\Foundation\Http\FormRequest;
/**
* RFC v0.2 §10.2 non-placement edits only. Placement (start_at,
* end_at, stage_id, lane) is NOT updateable here; placement changes
* route through POST /timetable/move so the cascade-bump and
* optimistic-lock contract is honoured.
*/
final class UpdatePerformanceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'notes' => ['sometimes', 'nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Stage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateStageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$stage = $this->route('stage');
$eventId = $stage instanceof Stage
? $stage->event_id
: ($this->route('event')?->id ?? null);
$stageId = $stage instanceof Stage ? $stage->id : null;
return [
'name' => [
'sometimes', 'required', 'string', 'max:120',
Rule::unique('stages', 'name')->where('event_id', $eventId)->ignore($stageId),
],
'color' => ['sometimes', 'required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'capacity' => ['sometimes', 'nullable', 'integer', 'min:0'],
'sort_order' => ['sometimes', 'nullable', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Portal;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validates the body of POST /p/artist/{token}/sections/{section}.
*
* Body shape: { values: { "<field-slug>": <value>, ... } }
*
* Per-field type validation runs inside FormValueService against the
* form_field_validation_rules rows; this request only enforces the
* envelope shape so we can reject malformed requests early.
*/
final class SubmitEngagementSectionRequest extends FormRequest
{
public function authorize(): bool
{
// Auth lives in the controller (via ArtistResolver token check).
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'values' => ['required', 'array'],
'values.*' => ['nullable'],
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Artist;
use App\Models\ArtistContact;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin ArtistContact
*/
final class ArtistContactResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'artist_id' => $this->artist_id,
'name' => $this->name,
'email' => $this->email,
'phone' => $this->phone,
'role' => $this->role,
'is_primary' => (bool) $this->is_primary,
'receives_briefing' => (bool) $this->receives_briefing,
'receives_infosheet' => (bool) $this->receives_infosheet,
];
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Artist;
use App\Enums\Artist\BumaHandledBy;
use App\Models\ArtistEngagement;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin ArtistEngagement
*
* Buma + VAT formulas (RFC v0.2 D26 must match Session 5 client-side
* preview):
*
* buma_amount = fee × buma_percentage / 100
* IFF buma_applicable && buma_handled_by === Organisation
* ELSE 0
*
* vat_grondslag = fee + (buma_amount IF Organisation handles buma ELSE 0)
*
* vat_amount = vat_grondslag × vat_percentage / 100 IF vat_applicable
* ELSE 0
*
* total_cost = fee + buma_amount + vat_amount
* + Σ deal_breakdown[*].amount
*/
final class ArtistEngagementResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$fee = (float) ($this->fee_amount ?? 0);
$bumaPercentage = (float) ($this->buma_percentage ?? 0);
$vatPercentage = (float) ($this->vat_percentage ?? 0);
$bumaAmount = ($this->buma_applicable && $this->buma_handled_by === BumaHandledBy::Organisation)
? round($fee * $bumaPercentage / 100, 2)
: 0.0;
$vatGrondslag = $fee + (
$this->buma_handled_by === BumaHandledBy::Organisation
? $bumaAmount
: 0.0
);
$vatAmount = $this->vat_applicable
? round($vatGrondslag * $vatPercentage / 100, 2)
: 0.0;
$breakdownTotal = 0.0;
foreach ((array) $this->deal_breakdown as $line) {
if (is_array($line) && isset($line['amount'])) {
$breakdownTotal += (float) $line['amount'];
}
}
$totalCost = round($fee + $bumaAmount + $vatAmount + $breakdownTotal, 2);
return [
'id' => $this->id,
'organisation_id' => $this->organisation_id,
'artist_id' => $this->artist_id,
'event_id' => $this->event_id,
'artist' => ArtistResource::make($this->whenLoaded('artist')),
'project_leader_id' => $this->project_leader_id,
'project_leader' => $this->whenLoaded('projectLeader', fn () => [
'id' => $this->projectLeader?->id,
'name' => trim(($this->projectLeader?->first_name ?? '').' '.($this->projectLeader?->last_name ?? '')),
'email' => $this->projectLeader?->email,
]),
'booking_status' => [
'value' => $this->booking_status?->value,
'label' => $this->booking_status?->label(),
],
'fee_amount' => $this->fee_amount,
'fee_currency' => $this->fee_currency,
'fee_type' => [
'value' => $this->fee_type?->value,
'label' => $this->fee_type?->label(),
],
'buma_applicable' => (bool) $this->buma_applicable,
'buma_percentage' => $this->buma_percentage,
'buma_handled_by' => [
'value' => $this->buma_handled_by?->value,
'label' => $this->buma_handled_by?->label(),
],
'vat_applicable' => (bool) $this->vat_applicable,
'vat_percentage' => $this->vat_percentage,
'deal_breakdown' => $this->deal_breakdown,
'deposit_percentage' => $this->deposit_percentage,
'deposit_due_date' => optional($this->deposit_due_date)->toIso8601String(),
'balance_due_date' => optional($this->balance_due_date)->toIso8601String(),
'payment_status' => [
'value' => $this->payment_status?->value,
'label' => $this->payment_status?->label(),
],
'crew_count' => $this->crew_count,
'guests_count' => $this->guests_count,
'requested_at' => optional($this->requested_at)->toIso8601String(),
'option_expires_at' => optional($this->option_expires_at)->toIso8601String(),
'advance_open_from' => optional($this->advance_open_from)->toIso8601String(),
'advance_open_to' => optional($this->advance_open_to)->toIso8601String(),
'advancing_completed_count' => $this->advancing_completed_count,
'advancing_total_count' => $this->advancing_total_count,
'notes' => $this->notes,
'computed' => [
'buma_amount' => $bumaAmount,
'vat_grondslag' => $vatGrondslag,
'vat_amount' => $vatAmount,
'breakdown_total' => $breakdownTotal,
'total_cost' => $totalCost,
],
'performances' => PerformanceResource::collection($this->whenLoaded('performances')),
'created_at' => optional($this->created_at)->toIso8601String(),
'updated_at' => optional($this->updated_at)->toIso8601String(),
'deleted_at' => optional($this->deleted_at)->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Artist
*/
final class ArtistResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$lifetime = $this->engagements()
->whereNotIn('booking_status', [
ArtistEngagementStatus::Cancelled->value,
ArtistEngagementStatus::Rejected->value,
ArtistEngagementStatus::Declined->value,
])
->count();
$upcoming = $this->engagements()
->whereNotIn('booking_status', [
ArtistEngagementStatus::Cancelled->value,
ArtistEngagementStatus::Rejected->value,
ArtistEngagementStatus::Declined->value,
])
->whereHas('event', fn ($q) => $q->where('end_date', '>=', now()->toDateString()))
->count();
return [
'id' => $this->id,
'organisation_id' => $this->organisation_id,
'name' => $this->name,
'slug' => $this->slug,
'default_genre_id' => $this->default_genre_id,
'default_genre' => GenreResource::make($this->whenLoaded('defaultGenre')),
'default_draw' => $this->default_draw,
'star_rating' => $this->star_rating,
'home_base_country' => $this->home_base_country,
'agent_company_id' => $this->agent_company_id,
'agent_company' => $this->whenLoaded(
'agentCompany',
fn () => [
'id' => $this->agentCompany?->id,
'name' => $this->agentCompany?->name,
'handles_buma' => (bool) ($this->agentCompany?->handles_buma ?? false),
],
),
'notes' => $this->notes,
'contacts' => ArtistContactResource::collection($this->whenLoaded('contacts')),
'engagements_summary' => [
'lifetime_count' => $lifetime,
'upcoming_count' => $upcoming,
],
'created_at' => optional($this->created_at)->toIso8601String(),
'updated_at' => optional($this->updated_at)->toIso8601String(),
'deleted_at' => optional($this->deleted_at)->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Artist;
use App\Models\Genre;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Genre
*/
final class GenreResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'organisation_id' => $this->organisation_id,
'name' => $this->name,
'color' => $this->color,
'sort_order' => $this->sort_order,
'is_active' => (bool) $this->is_active,
'created_at' => optional($this->created_at)->toIso8601String(),
'updated_at' => optional($this->updated_at)->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Artist;
use App\Models\Performance;
use App\Services\Artist\LaneResolver;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Performance
*/
final class PerformanceResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'engagement_id' => $this->engagement_id,
'event_id' => $this->event_id,
'stage_id' => $this->stage_id,
'lane' => (int) $this->lane,
'lane_resolved' => $this->resolveLane(),
'start_at' => optional($this->start_at)->toIso8601String(),
'end_at' => optional($this->end_at)->toIso8601String(),
'version' => (int) $this->version,
'notes' => $this->notes,
'warnings' => $this->computeWarnings(),
'engagement' => ArtistEngagementResource::make($this->whenLoaded('engagement')),
'stage' => StageResource::make($this->whenLoaded('stage')),
'created_at' => optional($this->created_at)->toIso8601String(),
'updated_at' => optional($this->updated_at)->toIso8601String(),
'deleted_at' => optional($this->deleted_at)->toIso8601String(),
];
}
/**
* Computed via LaneResolver over the (stage, sub-event) cohort.
* For parked performances (stage_id = null) the persisted lane is
* surfaced as-is the wachtrij is a flat list, not a lane grid.
*/
private function resolveLane(): int
{
if ($this->stage_id === null) {
return (int) $this->lane;
}
$cohort = Performance::query()
->where('stage_id', $this->stage_id)
->where('event_id', $this->event_id)
->get();
$resolved = app(LaneResolver::class)->resolve($cohort);
return $resolved[(string) $this->id] ?? (int) $this->lane;
}
/**
* RFC v0.2 D5 / D6 / D25 overlap, B2B, capacity warnings.
* Naive implementation for Session 2; refined as the timetable
* frontend lands in Session 4.
*
* @return array<int, string>
*/
private function computeWarnings(): array
{
$warnings = [];
if ($this->stage_id === null) {
return $warnings;
}
$start = CarbonImmutable::instance($this->start_at);
$end = CarbonImmutable::instance($this->end_at);
$peers = Performance::query()
->where('stage_id', $this->stage_id)
->where('event_id', $this->event_id)
->where('id', '!=', $this->id)
->get();
foreach ($peers as $other) {
$oStart = CarbonImmutable::instance($other->start_at);
$oEnd = CarbonImmutable::instance($other->end_at);
if ($start < $oEnd && $oStart < $end && (int) $other->lane === (int) $this->lane) {
$warnings[] = 'overlap';
break;
}
}
foreach ($peers as $other) {
$oEnd = CarbonImmutable::instance($other->end_at);
$oStart = CarbonImmutable::instance($other->start_at);
if ($oEnd->equalTo($start) || $oStart->equalTo($end)) {
$warnings[] = 'b2b';
break;
}
}
return array_values(array_unique($warnings));
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Artist;
use App\Models\Stage;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Stage
*/
final class StageResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'event_id' => $this->event_id,
'name' => $this->name,
'color' => $this->color,
'capacity' => $this->capacity,
'sort_order' => $this->sort_order,
'stage_days' => $this->whenLoaded(
'stageDays',
fn () => $this->stageDays->pluck('event_id')->all(),
),
'created_at' => optional($this->created_at)->toIso8601String(),
'updated_at' => optional($this->updated_at)->toIso8601String(),
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use App\Models\Person;
use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -13,6 +14,11 @@ final class MeResource extends JsonResource
{
public function toArray(Request $request): array
{
/** @var User $user */
$user = $this->resource;
$contexts = $this->resolveContexts($user);
return [
'id' => $this->id,
'first_name' => $this->first_name,
@@ -25,27 +31,32 @@ final class MeResource extends JsonResource
'locale' => $this->locale,
'avatar' => $this->avatar,
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
'organisations' => $this->whenLoaded('organisations', fn () =>
$this->organisations->map(fn ($org) => [
'id' => $org->id,
'name' => $org->name,
'slug' => $org->slug,
'role' => $org->pivot->role,
])
'organisations' => $this->whenLoaded('organisations', fn () => $this->organisations->map(fn ($org) => [
'id' => $org->id,
'name' => $org->name,
'slug' => $org->slug,
'role' => $org->pivot->role,
// Forward-compatible array form. The pivot stores a single
// role today; B2a emits it as a 1-element array so the
// frontend can treat the field as multi-role from day one.
// Multi-role pivot resolution is tracked in BACKLOG.md as
// TECH-PIVOT-ROLES-MULTI (ARCH discussion, not just a
// schema-column expansion).
'roles' => [$org->pivot->role],
])
),
'app_roles' => $this->getRoleNames()->values()->all(),
'permissions' => $this->getAllPermissions()->pluck('name')->values()->all(),
'portal_events' => $this->whenLoaded('persons', fn () =>
$this->persons->map(fn (Person $person) => [
'event_id' => $person->event_id,
'event_name' => $person->event->name,
'event_slug' => $person->event->slug,
'organisation_name' => $person->event->organisation->name,
'person_id' => $person->id,
'person_status' => $person->status,
'start_date' => $person->event->start_date?->toDateString(),
'end_date' => $person->event->end_date?->toDateString(),
])
'portal_events' => $this->whenLoaded('persons', fn () => $this->persons->map(fn (Person $person) => [
'event_id' => $person->event_id,
'event_name' => $person->event->name,
'event_slug' => $person->event->slug,
'organisation_name' => $person->event->organisation->name,
'person_id' => $person->id,
'person_status' => $person->status,
'start_date' => $person->event->start_date?->toDateString(),
'end_date' => $person->event->end_date?->toDateString(),
])
),
'mfa' => [
'enabled' => $this->mfa_enabled,
@@ -53,6 +64,51 @@ final class MeResource extends JsonResource
'confirmed_at' => $this->mfa_confirmed_at?->toIso8601String(),
'setup_required' => app(MfaService::class)->isMfaRequired($this->resource) && ! $this->mfa_enabled,
],
'platform' => [
'is_super_admin' => $user->hasRole('super_admin'),
],
'contexts' => $contexts,
];
}
/**
* Compute available + default UI contexts for this user.
*
* - portal: user has at least one Person record (volunteer-side).
* - organizer: super_admin OR membership in any Organisation pivot.
*
* Default precedence: super_admin organizer; otherwise the first
* available context wins (organizer before portal, mirroring the
* "familiar context wins on first login" rule from
* ARCH-CONSOLIDATION-2026-04 §4.3). When neither context is
* available, default falls back to 'portal' so the post-login
* landing logic has a safe target to resolve against.
*
* @return array{available: list<string>, default: string}
*/
private function resolveContexts(User $user): array
{
$hasPortal = $user->persons->isNotEmpty();
$hasOrganizer = $user->hasRole('super_admin') || $user->organisations->isNotEmpty();
$available = [];
if ($hasPortal) {
$available[] = 'portal';
}
if ($hasOrganizer) {
$available[] = 'organizer';
}
$default = match (true) {
$user->hasRole('super_admin') => 'organizer',
$hasOrganizer => 'organizer',
$hasPortal => 'portal',
default => 'portal',
};
return [
'available' => $available,
'default' => $default,
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Portal;
use App\Models\AdvanceSection;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Public payload returned by GET /p/artist/{token}.
*
* Carries just enough for the portal to render the section
* navigation: artist + event identification, plus the engagement's
* AdvanceSection rows with their submission_status and ordering.
*
* @property ArtistEngagement $resource
*/
final class EngagementPortalResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$engagement = $this->resource;
$artist = $engagement->getRelation('artist');
$event = $engagement->getRelation('event');
/** @var \Illuminate\Database\Eloquent\Collection<int, AdvanceSection> $sections */
$sections = $engagement->getRelation('advanceSections');
return [
'engagement_id' => (string) $engagement->id,
'artist' => $artist instanceof Artist ? [
'id' => (string) $artist->id,
'name' => (string) $artist->name,
] : null,
'event' => $event instanceof Event ? [
'id' => (string) $event->id,
'name' => (string) $event->name,
] : null,
'advancing_completed_count' => (int) $engagement->advancing_completed_count,
'advancing_total_count' => (int) $engagement->advancing_total_count,
'sections' => $sections
->sortBy('sort_order')
->values()
->map(static fn (AdvanceSection $section): array => [
'id' => (string) $section->id,
'name' => (string) $section->name,
'type' => $section->getRawOriginal('type'),
'sort_order' => (int) $section->sort_order,
'is_open' => (bool) $section->is_open,
'submission_status' => $section->getRawOriginal('submission_status'),
'last_submitted_at' => $section->getRawOriginal('last_submitted_at'),
])
->all(),
];
}
}

View File

@@ -6,7 +6,9 @@ namespace App\Listeners\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\FormBuilder\Bindings\FormBindingExceptionClassifier;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
@@ -21,10 +23,24 @@ use Throwable;
* FormSubmissionActionFailure in a separate transaction (survives
* inner rollback).
*
* v1.3 changes (per RFC v1.3.1 + ARCH-BINDINGS v1.2):
* - Q1 addition 1: writes identity_match_status='pending' inside the
* inner transaction so the HTTP response carries the right state
* for the IdentityMatchBanner first-paint copy. Final state is
* written by the queued TriggerPersonIdentityMatch.
* - Q1 addition 4: wraps applicator with config-driven deadline so
* a runaway apply() throws FormBindingApplicatorTimeoutException
* instead of hanging the public flow.
* - Q3 addition 2: outer-transaction catch uses
* FormBindingExceptionClassifier::classify to write
* failure_response_code on the parent submission. The action-failure
* row is the canonical machine-replayable artefact; the column is
* the response-shape driver.
*
* Throws are swallowed (RFC Q3) sibling listeners must keep running.
*
* SYNCHRONOUS by design does NOT implement ShouldQueue. Identity
* match runs after this in the registered listener order.
* SYNCHRONOUS by design does NOT implement ShouldQueue. Identity-match
* runs queued (post-v1.3) and is gated on apply_status=COMPLETED.
*/
final readonly class ApplyBindingsOnFormSubmit
{
@@ -37,28 +53,40 @@ final readonly class ApplyBindingsOnFormSubmit
return;
}
try {
DB::transaction(function () use ($submission): void {
$result = $this->applicator->apply($submission);
$deadlineSeconds = (int) config('form_builder.apply_deadline_seconds', 5);
FormSubmission::query()
->whereKey($submission->id)
->update([
'apply_status' => $result->applyStatus()->value,
'apply_completed_at' => now(),
]);
try {
DB::transaction(function () use ($submission, $deadlineSeconds): void {
$result = $this->applicator
->withDeadline($deadlineSeconds)
->apply($submission);
$updates = [
'apply_status' => $result->applyStatus()->value,
'apply_completed_at' => now(),
];
// Initial identity_match_status='pending' write (RFC §Q1
// v1.3 addition 1). Only meaningful when ApplyBindings
// produced a person subject; non-person purposes leave
// the column NULL per ARCH-BINDINGS §7.3 contract.
if ($result->provisionedSubjectType === 'person'
|| $submission->subject_type === 'person') {
$updates['identity_match_status'] = 'pending';
}
if ($result->provisionedSubjectType !== null && $submission->subject_type === null) {
// ApplyBindings just provisioned a Person; reflect it
// on the submission so TriggerPersonIdentityMatch (next
// sync listener) can find it.
FormSubmission::query()
->whereKey($submission->id)
->update([
'subject_type' => $result->provisionedSubjectType,
'subject_id' => $result->provisionedSubjectId,
]);
// queued listener) can find it and the gating-invariant
// sees a coherent (subject_type, subject_id) pair.
$updates['subject_type'] = $result->provisionedSubjectType;
$updates['subject_id'] = $result->provisionedSubjectId;
}
FormSubmission::query()
->whereKey($submission->id)
->update($updates);
});
} catch (Throwable $e) {
// OUTSIDE the failed transaction — survives rollback.
@@ -74,6 +102,9 @@ final readonly class ApplyBindingsOnFormSubmit
'exception_trace' => $e->getTraceAsString(),
'context' => [
'purpose' => $purposeValue,
'submission_id' => $e instanceof FormBindingApplicatorException
? $e->submissionId
: (string) $submission->id,
],
]);
FormSubmission::query()
@@ -81,6 +112,7 @@ final readonly class ApplyBindingsOnFormSubmit
->update([
'apply_status' => ApplyStatus::FAILED->value,
'apply_completed_at' => now(),
'failure_response_code' => FormBindingExceptionClassifier::classify($e),
]);
});
Log::error('form-builder.apply.transaction_rolled_back', [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Listeners\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionSubmitted;
@@ -24,6 +25,11 @@ use Illuminate\Support\Facades\Log;
* person's linked user. No-ops when person.user_id is null (deferred
* sync runs on PersonIdentityService::confirmMatch).
*
* Gating-invariant first statement per ARCH-BINDINGS §5.6: skip unless
* apply_status is COMPLETED. PARTIAL and FAILED both fall through
* rebuilding tags against a Person whose tag-binding may have been the
* binding that failed would propagate partial state into derived data.
*
* Failure mode: log at error level; never throw. Event propagation must
* reach sibling listeners (§31.1 identity, §31.3 shifts, §31.8 crowd lists).
*/
@@ -43,11 +49,25 @@ final class SyncTagPickerSelectionsOnSubmit implements ShouldQueue
public function handle(FormSubmissionSubmitted $event): void
{
try {
// Gating-invariant first statement per ARCH-BINDINGS §5.6.
// The fresh() reload is required because the inner-txn commit
// happens between dispatch and worker pickup; the in-memory
// event submission may carry pre-commit state.
$submission = $event->submission->fresh(['schema']);
if ($submission === null) {
return;
}
if ($submission->apply_status !== ApplyStatus::COMPLETED) {
Log::info('form-builder.queued-listener.skipped_apply_failed', [
'listener' => self::class,
'submission_id' => (string) $submission->id,
'apply_status' => $submission->apply_status?->value,
]);
return;
}
$schema = $submission->schema;
if ($schema === null) {
return;

View File

@@ -4,112 +4,139 @@ declare(strict_types=1);
namespace App\Listeners\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionIdentityMatchResolved;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\IdentityMatchInvariantViolation;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Person;
use App\Services\PersonIdentityService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
/**
* ARCH §31.1 trigger PersonIdentityService::detectMatches on
* event_registration submissions and record the outcome on the
* submission so the portal can tell the submitter what's happening.
* RFC-WS-6 §Q1 v1.3 (queued) + §Q2 (invariant) + §Q1 v1.3 addition 2 (broadcast).
*
* Runs asynchronously after ApplyBindingsOnFormSubmit commits. Identity
* matching joins the persons table scoped to organisation; in large orgs
* (10k+ Persons) this can take seconds at peak load. Keeping it sync would
* block PHP-FPM workers during public-form submission spikes operationally
* unacceptable for enterprise SaaS. The IdentityMatchBanner first-paint
* copy stays correct because ApplyBindingsOnFormSubmit writes
* identity_match_status='pending' inside its inner transaction; this
* listener writes the final state and broadcasts on the
* `submission.{id}` private channel so the frontend can refetch.
*
* Gating-invariant per ARCH-BINDINGS §5.6: skip unless apply_status is
* COMPLETED. PARTIAL and FAILED both fall through to the early-return
* sibling state is incoherent, identity-match against possibly-half-applied
* data is meaningless.
*
* The post-ApplyBindings invariant per §Q2: subject_type='person' AND
* subject_id IS NOT NULL, OR apply_status=FAILED. No third state exists.
* If we see subject_type='person' AND subject_id IS NULL AND
* apply_status=COMPLETED, that's a structural defect strict throw via
* IdentityMatchInvariantViolation routes to GlitchTip + (via the queue
* exception handler) form_submission_action_failures.
*
* States written to form_submissions.identity_match_status:
* - 'matched' the person is already linked to a user account
* - 'pending' one or more PersonIdentityMatch(pending) rows exist
* OR the submission is public (no subject yet; organiser
* will attach a person and matching runs later)
* - 'none' the person exists, is unlinked, and nothing matched
*
* Failure mode per §31.1: log at error level, never rethrow so sibling
* listeners on the same event (§31.10 tag sync, §31.3 shift provisioning)
* keep running.
*
* Runs synchronously (no ShouldQueue) so identity_match_status is
* already written by the time the HTTP submit-response serialises the
* submission the portal's IdentityMatchBanner then renders on first
* confirmation-page load instead of after a queue worker tick. When
* FORM-05 proper adds heavier value-based matching, that work will
* dispatch as a separate queued job from within this listener so the
* eager state transition stays sync and the slow resolution stays
* async.
* - 'pending' one or more PersonIdentityMatch(pending) candidates exist
* - 'none' no candidates and not linked
*/
final class TriggerPersonIdentityMatchOnFormSubmit
final class TriggerPersonIdentityMatchOnFormSubmit implements ShouldQueue
{
use InteractsWithQueue;
// Default queue connection — redis in production, sync in tests.
public string $queue = 'default';
public function __construct(
private readonly PersonIdentityService $identityService,
) {}
public function handle(FormSubmissionSubmitted $event): void
{
try {
$submission = $event->submission->fresh(['schema']);
if ($submission === null) {
return;
}
$schema = $submission->schema;
if ($schema === null) {
return;
}
$purpose = $schema->purpose instanceof \BackedEnum
? $schema->purpose->value
: (string) $schema->purpose;
if ($purpose !== FormPurpose::EVENT_REGISTRATION->value) {
return;
}
$status = $this->resolveStatus($submission);
// Use a raw UPDATE so we don't re-fire Eloquent events / an
// observer cascade on the submission itself.
FormSubmission::query()
->whereKey($submission->id)
->update(['identity_match_status' => $status]);
} catch (\Throwable $e) {
Log::error('form-builder.identity-match.listener_failed', [
'submission_id' => $event->submission->id,
'message' => $e->getMessage(),
]);
// Gating-invariant first statement per ARCH-BINDINGS §5.6.
// The fresh() reload is required because the inner-txn commit +
// outer-txn failure-record write happens between dispatch and
// worker pickup; the in-memory event submission may be stale.
$submission = $event->submission->fresh(['schema']);
if (! $submission instanceof FormSubmission) {
return;
}
}
private function resolveStatus(FormSubmission $submission): string
{
if ($submission->subject_type !== 'person' || $submission->subject_id === null) {
// Post-WS-6 (RFC Q2): this path should be unreachable for
// event_registration submissions because ApplyBindingsOnFormSubmit
// provisions the Person synchronously before this listener fires.
// If we reach here, either:
// - ApplyBindings failed silently (check form_submission_action_failures)
// - Schema is misconfigured (no email binding, no identity-key)
// - Different purpose where subject genuinely is null
// Failsafe: preserve the existing 'pending' state so portal banner
// still renders sensibly, and surface the misconfiguration in logs.
Log::warning('form-builder.identity-match.no_person_subject_post_apply', [
if ($submission->apply_status !== ApplyStatus::COMPLETED) {
Log::info('form-builder.queued-listener.skipped_apply_failed', [
'listener' => self::class,
'submission_id' => (string) $submission->id,
'schema_id' => (string) $submission->form_schema_id,
'subject_type' => $submission->subject_type,
'apply_status' => $submission->apply_status?->value,
]);
return 'pending';
return;
}
// Non-event_registration purposes: no-op by design. Identity-match
// is a person-resolution flow specific to public registration.
$schema = $submission->schema;
if ($schema === null) {
return;
}
$purpose = $schema->purpose instanceof \BackedEnum
? $schema->purpose->value
: (string) $schema->purpose;
if ($purpose !== FormPurpose::EVENT_REGISTRATION->value) {
return;
}
// Non-person subjects on event_registration: also a no-op (defensive).
if ($submission->subject_type !== 'person') {
return;
}
// Invariant per §Q2: subject_id IS NOT NULL when apply_status=COMPLETED
// and subject_type='person'. Violation = structural defect.
if ($submission->subject_id === null) {
throw new IdentityMatchInvariantViolation(sprintf(
"subject_type='person' but subject_id=null after ApplyBindings COMPLETED. submission_id=%s",
$submission->id,
));
}
$person = Person::withoutGlobalScopes()->find($submission->subject_id);
if ($person === null) {
return 'none';
}
// The person was deleted between ApplyBindings COMPLETED and this
// worker pickup. Rare but possible; treat as a no-match terminal
// state so the banner shows a sensible final copy.
FormSubmission::query()
->whereKey($submission->id)
->update(['identity_match_status' => 'none']);
if ($person->user_id !== null) {
return 'matched';
return;
}
$matches = $this->identityService->detectMatches($person);
$status = match (true) {
$person->user_id !== null => 'matched',
$matches->isNotEmpty() => 'pending',
default => 'none',
};
return $matches->isNotEmpty() ? 'pending' : 'none';
FormSubmission::query()
->whereKey($submission->id)
->update(['identity_match_status' => $status]);
// Per RFC §Q1 v1.3 addition 2 — broadcast on the submission's
// private channel so the frontend portal IdentityMatchBanner can
// refetch the submission resource and transition copy from
// "we're checking matches…" to the final state without a manual
// reload. matchCount is an ephemeral DTO field; not persisted.
broadcast(new FormSubmissionIdentityMatchResolved(
submissionId: (string) $submission->id,
status: $status,
matchCount: $matches->count(),
))->toOthers();
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Listeners\Observability;
use App\Enums\Observability\ActorType;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\Events\TokenAuthenticated;
use Sentry\State\Scope;
use function Sentry\configureScope;
/**
* Binds auth-scope context to Sentry and Laravel log context on every
* successful authentication.
*
* Listens to TWO events:
* - {@see Authenticated} fires from SessionGuard (login flow / future
* cookie-session authenticators).
* - {@see TokenAuthenticated} fires from {@see \Laravel\Sanctum\Guard}
* on every bearer-token resolution. Crewli's HTTP flow is
* bearer-token (CookieBearerToken middleware reads the httpOnly
* cookie and injects Authorization: Bearer); without listening to
* TokenAuthenticated, no auth-scope tags would ever bind on live
* requests a regression the offline tests miss because they
* dispatch Authenticated directly.
*
* Auth-scope tags (user_id, actor_type, organisation_id, actor_scope)
* are decoupled from route-scope tags (BindSentryRouteContext middleware)
* so that authentication-mechanism additions don't require touching
* every route-group's middleware stack.
*
* Impersonation re-binding (target user_id + impersonation.* tags) is
* co-located in HandleImpersonation middleware and runs after the user
* swap.
*
* RFC-WS-7 §3.6, §3.13 (Log::withContext OBS-2 fix).
*/
final class AuthScopeContextListener
{
public function handle(Authenticated $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
$this->bindForUser($user);
}
public function handleTokenAuthenticated(TokenAuthenticated $event): void
{
$tokenable = $event->token->tokenable ?? null;
if (! $tokenable instanceof User) {
return;
}
$this->bindForUser($tokenable);
}
private function bindForUser(User $user): void
{
$request = request();
$actorType = ActorType::resolve($user, $request);
[$organisationId, $actorScope] = $this->resolveTenantContext($user, $request);
configureScope(static function (Scope $scope) use ($user, $actorType, $organisationId, $actorScope): void {
$scope->setUser([
'id' => $user->id,
'username' => $user->id, // RFC §3.8: ULID, never email.
]);
// Default baseline; HandleImpersonation middleware overschrijft
// naar 'true' + zet impersonation.impersonator_user_id wanneer
// impersonation actief is. RFC §3.6 vereist always-present
// binary signal voor betrouwbare filtering.
$scope->setTag('impersonation.active', 'false');
$scope->setTag('user_id', $user->id);
$scope->setTag('actor_type', $actorType->value);
$scope->setTag('actor_scope', $actorScope);
if ($organisationId !== null) {
$scope->setTag('organisation_id', $organisationId);
}
});
Log::withContext(array_filter([
'user_id' => $user->id,
'organisation_id' => $organisationId,
'actor_scope' => $actorScope,
], static fn ($v) => $v !== null && $v !== ''));
}
/**
* Resolves organisation_id and actor_scope per RFC §3.6 (refined after
* the PR-2 live smoke test).
*
* Resolution priority:
* 1. Route-scoped: {organisation} or {event} URI parameter resolves
* to an Organisation/Event actor_scope=organisation.
* 2. Portal token: portal_event request attribute populated by
* PortalTokenMiddleware actor_scope=organisation.
* 3. super_admin on admin.* route actor_scope=platform; no
* organisation_id tag (forced current-org fallback would produce
* misleading attribution).
* 4. Default authenticated user actor_scope=user, organisation_id
* is omitted because Crewli's User<->Organisation is many-to-many;
* no reliable single-org hint exists at user level.
*
* @return array{0: ?string, 1: string} [organisation_id|null, actor_scope]
*/
private function resolveTenantContext(User $user, ?Request $request): array
{
if ($request === null) {
return [null, 'user'];
}
// 1a. Explicit {organisation} route parameter.
$route = $request->route();
if ($route !== null) {
$orgParam = $route->parameter('organisation');
if ($orgParam instanceof Organisation) {
return [$orgParam->id, 'organisation'];
}
if (is_string($orgParam) && $orgParam !== '') {
return [$orgParam, 'organisation'];
}
// 1b. {event} parameter — derive org via event.organisation_id.
$eventParam = $route->parameter('event');
if ($eventParam instanceof Event) {
return [$eventParam->organisation_id, 'organisation'];
}
}
// 2. Portal token (artist/supplier/press flows).
$portalEvent = $request->attributes->get('portal_event');
if ($portalEvent instanceof Event) {
return [$portalEvent->organisation_id, 'organisation'];
}
// 3. super_admin on admin.* (Crewli's platform-admin route prefix).
if ($user->hasRole('super_admin') && $route !== null) {
$name = $route->getName();
if (is_string($name) && str_starts_with($name, 'admin.')) {
return [null, 'platform'];
}
}
// 4. Default user-scope: no org attribution (Crewli's User has no
// current_organisation_id; many-to-many membership precludes a
// reliable single-org hint).
return [null, 'user'];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Listeners\Observability;
use Illuminate\Queue\Events\JobProcessing;
use Sentry\State\Scope;
use function Sentry\configureScope;
/**
* Listener for {@see JobProcessing} that attaches the `queue.attempt` tag
* (RFC-WS-7 §3.6) to the active Sentry scope before the job runs. Default
* stack-trace grouping is preserved RFC §3.11 explicitly forbids
* per-attempt fingerprinting so retries that eventually succeed remain
* grouped with retries that always fail.
*/
final class TagJobAttemptOnSentry
{
public function handle(JobProcessing $event): void
{
$attempt = (string) $event->job->attempts();
configureScope(static function (Scope $scope) use ($attempt): void {
$scope->setTag('queue.attempt', $attempt);
});
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Enums\Artist\AdvanceSectionType;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
final class AdvanceSection extends Model
{
use HasFactory;
use HasUlids;
use LogsActivity;
protected static function booted(): void
{
self::addGlobalScope(new OrganisationScope);
}
/** @return array{via: class-string, fk: string} */
public static function tenantScopeStrategy(): array
{
return ['via' => ArtistEngagement::class, 'fk' => 'engagement_id'];
}
protected $fillable = [
'engagement_id',
'name',
'type',
'is_open',
'open_from',
'open_to',
'sort_order',
'submission_status',
'last_submitted_at',
'last_submitted_by',
'submission_diff',
];
protected function casts(): array
{
return [
'type' => AdvanceSectionType::class,
'submission_status' => AdvanceSectionSubmissionStatus::class,
'is_open' => 'boolean',
'open_from' => 'datetime',
'open_to' => 'datetime',
'sort_order' => 'integer',
'last_submitted_at' => 'datetime',
'submission_diff' => 'array',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontLogEmptyChanges();
}
public function engagement(): BelongsTo
{
return $this->belongsTo(ArtistEngagement::class, 'engagement_id');
}
public function submissions(): HasMany
{
return $this->hasMany(AdvanceSubmission::class);
}
}

Some files were not shown because too many files have changed in this diff Show More