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)
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
- 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>
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>
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>
§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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
§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>
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>
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>
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>
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>
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>
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>
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>
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>
Per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN.
WS-6 v1.3-delta D2 (PR #1123a5696) 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>
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 #1123a5696).
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>
§1 Status: add Implementation status line citing D1 (PR #10c6f4d1b)
and D2 (PR #1123a5696), 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>
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>
~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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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
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.
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>
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>
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>
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>
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>
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).
/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.
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.
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.
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.
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.
Registers PreToolUse, PostToolUse, and SessionStart hooks for the
deterministic guard-rail layer. settings.local.json stays gitignored
for per-user overrides.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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
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.
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.
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'"
block "blanket dependency update bumps everything without review""Use targeted 'composer require <pkg>' or 'pnpm add <pkg>' to bump one package at a time"
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
ifecho"$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
ifecho"$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"
**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)
| 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.
**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. |
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.
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.
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.
- 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
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.
- 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
| **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) |
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.