RFC-TIMETABLE v0.2 Session 2 — Backend API + business logic #16
Reference in New Issue
Block a user
Delete Branch "feat/timetable-session-2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
validation rules, 6 API resources, 6 thin controllers, RFC §6 routes
parkedevent), R1 60s Redis idempotency middleware onPOST /timetable/move,artist:demote-expired-optionsdaily commandsame-shape findings only; no novel error shape
Phase A decision (Option B, role-based authorization)
Spatie permissions are not used in the codebase today; RFC §9 permission
strings (
events.manage_programetc.) are documented in policy classdocblocks and authorised role-based, matching
PersonPolicy/ShiftPolicy.The cross-cutting migration is tracked in BACKLOG as
AUTH-PERMISSIONS-MIGRATION.Notable schema realities surfaced
events.start_date/end_date(date type) — NOTstart_at/end_at.WithinEventBoundsbridges viastartOfDay()/endOfDay(). Schemaupgrade for real event hours tracked under EVENT-START-END-TIME.
performanceshas noorganisation_idcolumn — cross-tenant viaFK-chain through
engagement_id.Both addressed before final commit; relevant tests cover them.
Activity log shape (RFC §8)
LaneCascadeService::move()callsdisableLogging()on every save insidethe cascade transaction (locked + bumped peers + park path). The two
explicit
activity()->event('moved'|'parked')entries are the only auditrecords per move; cascade-bumped peers receive zero entries. Verified by
two new tests.
Out of scope (Session 3+)
Form-Builder integration, frontend timetable, notification framework wiring.
Test plan
php artisan test— full suite green (1708 ✓)composer analyse— clean (0 errors above baseline ✓)php artisan migrate:fresh --seed --env=testing— clean ✓schedule performance, drag-move (cascade), park, demote expired option
🤖 Generated with Claude Code
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>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>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 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>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>Nine test files under tests/Feature/Artist/ exercising: ArtistEngagementStateMachineTest 8 tests — terminal blocks, conditional gates (Option/Contracted), full happy path, cancel cascade LaneCascadeServiceTest 5 tests — simple move, cascade-bump, version mismatch, park, unpark BumaVatCalculationTest 6 tests — D26 formula coverage: Organisation/BookingAgency/NotApplicable, VAT off, breakdown sum, zero fee DemoteExpiredOptionsTest 4 tests — expired demote, future untouched, non-Option untouched, run twice → single option_expired entry IdempotencyKey60sRedisTest 4 tests — missing header 400, first cache, replay header, failed not cached ArtistControllerTest 8 tests — index/create/destroy + cross- tenant + duplicate detection + restore StageControllerTest 7 tests — create + uniqueness, destroy cascade-park, reorder permutation, replaceDays orphan 409 + force_orphan ArtistEngagementControllerTest 5 tests — index/create/update/destroy + 422 on invalid status transition TimetableMoveControllerTest 3 tests — happy path with idempotency header, missing header → 400, version mismatch → 409 ArtistPolicyTest 6 tests — role checks, cross-tenant denial, super_admin bypass, D27 active- engagement gate ActivityLogShapeTest 4 tests — performance.moved cascade props, status_changed vs cancelled, stage.day_added subject + props, stage.reordered on Event subject Bug fixes surfaced by Phase C: Schema reality: events table uses `start_date`/`end_date` (date), not `start_at`/`end_at`. Updated WithinEventBounds rule and the two stage_day resolvers (LaneCascadeService + MoveTimetablePerformanceRequest) to query the actual columns. ArtistResource.engagements_summary upcoming filter likewise. performances table has no organisation_id column (FK-chain via engagement_id). Removed the org-id filter from the Rule::exists in MoveTimetablePerformanceRequest; cross-tenant is caught by the policy in TimetableMoveController. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>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>