10 Commits

Author SHA1 Message Date
5ab68ddbb3 chore(timetable): bump phpstan baseline for park-path engagement access
Single-count drift: the new park-path explicit activity entry in
LaneCascadeService accesses $parked->engagement?->organisation_id
(same shape as the existing schedule-path access, which the baseline
already accepts). Baseline grew 1740 → 1741 errors; same-shape, no
novel rule.

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

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

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

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

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

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

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

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

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

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

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

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

Bug fixes surfaced by Phase C:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:54:20 +02:00
36 changed files with 3145 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Artist;
use App\Models\Event;
use App\Models\Stage;
use App\Models\StageDay;
use App\Rules\Artist\StageActiveOnEvent;
@@ -30,13 +29,15 @@ final class MoveTimetablePerformanceRequest extends FormRequest
public function rules(): array
{
$event = $this->route('event');
$organisationId = $event instanceof Event ? $event->organisation_id : null;
$resolvedEventId = $this->resolveTargetEventId();
return [
// performances has no organisation_id column (FK-chain via
// engagement_id); cross-tenant is caught by the policy in
// TimetableMoveController via Gate::authorize('move', ...).
'performance_id' => [
'required', 'string', 'max:30',
Rule::exists('performances', 'id')->where('organisation_id', $organisationId),
Rule::exists('performances', 'id'),
],
'target_stage_id' => [
'nullable', 'string', 'max:30',
@@ -96,9 +97,9 @@ final class MoveTimetablePerformanceRequest extends FormRequest
$match = StageDay::query()
->where('stage_id', $stage->id)
->join('events', 'events.id', '=', 'stage_days.event_id')
->where('events.start_at', '<=', $start)
->where('events.end_at', '>=', $start)
->orderBy('events.start_at', 'desc')
->where('events.start_date', '<=', $start->toDateString())
->where('events.end_date', '>=', $start->toDateString())
->orderBy('events.start_date', 'desc')
->limit(1)
->value('stage_days.event_id');

View File

@@ -33,7 +33,7 @@ final class ArtistResource extends JsonResource
ArtistEngagementStatus::Rejected->value,
ArtistEngagementStatus::Declined->value,
])
->whereHas('event', fn ($q) => $q->where('end_at', '>=', now()))
->whereHas('event', fn ($q) => $q->where('end_date', '>=', now()->toDateString()))
->count();
return [

View File

@@ -56,8 +56,17 @@ final class Artist extends Model
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontLogEmptyChanges();
->logOnly(['name', 'slug', 'default_genre_id', 'default_draw', 'agent_company_id'])
->logOnlyDirty()
->dontLogIfAttributesChangedOnly(['updated_at'])
->useLogName('artist');
}
public function tapActivity(Activity $activity, string $eventName): void
{
$properties = $activity->properties?->toArray() ?? [];
$properties['organisation_id'] = $this->organisation_id;
$activity->properties = collect($properties);
}
private function generateUniqueSlug(string $name): string

View File

@@ -87,8 +87,26 @@ final class ArtistEngagement extends Model
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontLogEmptyChanges();
->logOnly([
'booking_status',
'fee_amount', 'fee_currency', 'fee_type',
'buma_applicable', 'buma_percentage', 'buma_handled_by',
'vat_applicable', 'vat_percentage',
'project_leader_id',
'option_expires_at',
'payment_status',
])
->logOnlyDirty()
->dontLogIfAttributesChangedOnly(['updated_at'])
->useLogName('artist_engagement');
}
public function tapActivity(Activity $activity, string $eventName): void
{
$properties = $activity->properties?->toArray() ?? [];
$properties['organisation_id'] = $this->organisation_id;
$properties['event_id'] = $this->event_id;
$activity->properties = collect($properties);
}
public function organisation(): BelongsTo

View File

@@ -43,8 +43,17 @@ final class Genre extends Model
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontLogEmptyChanges();
->logOnly(['name', 'color', 'is_active', 'sort_order'])
->logOnlyDirty()
->dontLogIfAttributesChangedOnly(['updated_at'])
->useLogName('genre');
}
public function tapActivity(Activity $activity, string $eventName): void
{
$properties = $activity->properties?->toArray() ?? [];
$properties['organisation_id'] = $this->organisation_id;
$activity->properties = collect($properties);
}
public function organisation(): BelongsTo

View File

@@ -55,8 +55,22 @@ final class Performance extends Model
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontLogEmptyChanges();
->logOnly(['start_at', 'end_at', 'stage_id', 'lane', 'notes'])
->logOnlyDirty()
// Performance.version is bumped by PerformanceObserver on every
// dirty save. Skip the auto-log when *only* updated_at + version
// moved — those rows correspond to bookkeeping touches, not
// user-meaningful changes (D14).
->dontLogIfAttributesChangedOnly(['updated_at', 'version'])
->useLogName('performance');
}
public function tapActivity(Activity $activity, string $eventName): void
{
$properties = $activity->properties?->toArray() ?? [];
$properties['event_id'] = $this->event_id;
$properties['organisation_id'] = $this->engagement?->organisation_id;
$activity->properties = collect($properties);
}
public function engagement(): BelongsTo

View File

@@ -50,8 +50,18 @@ final class Stage extends Model
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->dontLogEmptyChanges();
->logOnly(['name', 'color', 'capacity', 'sort_order'])
->logOnlyDirty()
->dontLogIfAttributesChangedOnly(['updated_at'])
->useLogName('stage');
}
public function tapActivity(Activity $activity, string $eventName): void
{
$properties = $activity->properties?->toArray() ?? [];
$properties['event_id'] = $this->event_id;
$properties['organisation_id'] = $this->event?->organisation_id;
$activity->properties = collect($properties);
}
public function event(): BelongsTo

View File

@@ -37,8 +37,8 @@ final class WithinEventBounds implements ValidationRule
}
$candidate = CarbonImmutable::parse((string) $value);
$start = CarbonImmutable::instance($event->start_at);
$end = CarbonImmutable::instance($event->end_at);
$start = CarbonImmutable::instance($event->start_date)->startOfDay();
$end = CarbonImmutable::instance($event->end_date)->endOfDay();
if ($candidate->lt($start) || $candidate->gt($end)) {
$fail(sprintf(

View File

@@ -103,11 +103,30 @@ final class ArtistEngagementService
): ArtistEngagement {
$from = $this->coerceStatus($engagement->booking_status);
if ($from === $to) {
return $engagement;
}
$this->validateTransition($from, $to, $engagement);
$engagement->booking_status = $to;
$engagement->save();
// RFC §8 — explicit `status_changed` audit event, separate from
// the auto-logged `updated` row that captures the diff. Pairs
// with the `cancelled` and `option_expired` events emitted by
// cancel() and the DemoteExpiredOptions command respectively.
activity('artist_engagement')
->performedOn($engagement)
->event($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed')
->withProperties([
'from' => $from->value,
'to' => $to->value,
'organisation_id' => $engagement->organisation_id,
'event_id' => $engagement->event_id,
])
->log($to === ArtistEngagementStatus::Cancelled ? 'cancelled' : 'status_changed');
return $engagement;
}

View File

@@ -74,9 +74,21 @@ final class LaneCascadeService
if ($targetLane !== null) {
$locked->lane = $targetLane;
}
$locked->disableLogging(); // suppress auto-log; park is captured in explicit entry below
$locked->save();
return new MoveResult($locked->refresh(), []);
$parked = $locked->refresh();
activity('performance')
->performedOn($parked)
->event('parked')
->withProperties([
'event_id' => $parked->event_id,
'organisation_id' => $parked->engagement?->organisation_id,
])
->log('parked');
return new MoveResult($parked, []);
}
if ($start === null || $end === null || $targetLane === null) {
@@ -104,6 +116,7 @@ final class LaneCascadeService
foreach ($existingOnLane as $other) {
if ($this->overlaps($start, $end, $other->start_at, $other->end_at)) {
$other->lane = (int) $other->lane + 1;
$other->disableLogging(); // suppress auto-log; cascade is captured in parent entry
$other->save();
$cascaded[] = $other->refresh();
}
@@ -114,9 +127,27 @@ final class LaneCascadeService
$locked->start_at = $start;
$locked->end_at = $end;
$locked->lane = $targetLane;
$locked->disableLogging(); // suppress auto-log; move is captured in explicit entry below
$locked->save();
return new MoveResult($locked->refresh(), $cascaded);
$moved = $locked->refresh();
// RFC §8 — single parent activity entry summarising the cascade.
// All saves inside this transaction call disableLogging() so the
// auto-log trait does not write per-row 'updated' entries; this
// explicit entry is the only audit record for the move.
activity('performance')
->performedOn($moved)
->event('moved')
->withProperties([
'cascade_count' => count($cascaded),
'cascaded_ids' => array_map(fn ($p): string => (string) $p->id, $cascaded),
'event_id' => $moved->event_id,
'organisation_id' => $moved->engagement?->organisation_id,
])
->log('moved');
return new MoveResult($moved, $cascaded);
});
}
@@ -131,9 +162,9 @@ final class LaneCascadeService
{
return $stage->stageDays()
->join('events', 'events.id', '=', 'stage_days.event_id')
->where('events.start_at', '<=', $start)
->where('events.end_at', '>=', $start)
->orderBy('events.start_at', 'desc')
->where('events.start_date', '<=', $start->toDateString())
->where('events.end_date', '>=', $start->toDateString())
->orderBy('events.start_date', 'desc')
->limit(1)
->value('stage_days.event_id');
}

View File

@@ -67,6 +67,32 @@ final class StageDayService
]);
}
// RFC §8 — one activity entry per added/removed event_id,
// performed-on the parent stage so the audit log groups
// changes per stage rather than per pivot row.
$stage->loadMissing('event');
$organisationId = $stage->event?->organisation_id;
foreach ($added as $eventId) {
activity('stage')
->performedOn($stage)
->event('day_added')
->withProperties([
'event_id' => $eventId,
'organisation_id' => $organisationId,
])
->log('day_added');
}
foreach ($removed as $eventId) {
activity('stage')
->performedOn($stage)
->event('day_removed')
->withProperties([
'event_id' => $eventId,
'organisation_id' => $organisationId,
])
->log('day_removed');
}
return ['added' => $added, 'removed' => $removed];
});
}

View File

@@ -79,6 +79,16 @@ final class StageService
->where('id', $stageId)
->update(['sort_order' => $position]);
}
activity('stage')
->on($event)
->event('reordered')
->withProperties([
'event_id' => $event->id,
'organisation_id' => $event->organisation_id,
'stage_ids' => $orderedStageIds,
])
->log('reordered');
});
}
}

View File

@@ -59,6 +59,9 @@ return Application::configure(basePath: dirname(__DIR__))
'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class,
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'impersonation' => \App\Http\Middleware\HandleImpersonation::class,
// RFC-TIMETABLE v0.2 R1 — 60s Redis idempotency window for
// POST /timetable/move. Narrow scope by design.
'idempotency.60s' => \App\Http\Middleware\IdempotencyKey60sRedis::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -1,5 +1,23 @@
parameters:
ignoreErrors:
-
message: '#^Comparison operation "\>" between 0 and 0 is always false\.$#'
identifier: greater.alwaysFalse
count: 1
path: app/Console/Commands/Artist/DemoteExpiredOptions.php
-
message: '#^Strict comparison using \!\=\= between string and App\\Enums\\Artist\\ArtistEngagementStatus\:\:Option will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: app/Console/Commands/Artist/DemoteExpiredOptions.php
-
message: '#^Unreachable statement \- code above always terminates\.$#'
identifier: deadCode.unreachable
count: 1
path: app/Console/Commands/Artist/DemoteExpiredOptions.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#'
identifier: property.notFound
@@ -756,6 +774,36 @@ parameters:
count: 1
path: app/Http/Controllers/Controller.php
-
message: '#^Cannot access property \$value on string\.$#'
identifier: property.nonObject
count: 1
path: app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Requests/Api/V1/Artist/UpdateArtistEngagementRequest.php
-
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Requests/Api/V1/Artist/UpdateArtistRequest.php
-
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Requests/Api/V1/Artist/UpdateGenreRequest.php
-
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Requests/Api/V1/Artist/UpdateStageRequest.php
-
message: '#^Method App\\Http\\Requests\\Api\\V1\\Auth\\MfaConfirmRequest\:\:rules\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
@@ -1062,6 +1110,120 @@ parameters:
count: 1
path: app/Http/Resources/Admin/ImpersonationSessionResource.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$email\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$first_name\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$last_name\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Call to function is_array\(\) with string will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Cannot access property \$value on string\.$#'
identifier: property.nonObject
count: 4
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Cannot call method label\(\) on string\.$#'
identifier: method.nonObject
count: 4
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Offset ''amount'' on \*NEVER\* in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse
count: 2
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Strict comparison using \=\=\= between string and App\\Enums\\Artist\\BumaHandledBy\:\:Organisation will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 2
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Using nullsafe method call on non\-nullable type string\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 3
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Using nullsafe property access "\?\-\>first_name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Using nullsafe property access "\?\-\>last_name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Using nullsafe property access on non\-nullable type string\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 3
path: app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$handles_buma\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistResource.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistResource.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistResource.php
-
message: '#^Using nullsafe property access "\?\-\>handles_buma" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Resources/Api/V1/Artist/ArtistResource.php
-
message: '#^Parameter \#1 \$date of static method Carbon\\CarbonImmutable\:\:instance\(\) expects DateTimeInterface, string given\.$#'
identifier: argument.type
count: 6
path: app/Http/Resources/Api/V1/Artist/PerformanceResource.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\Api\\V1\\CompanyResource\:\:\$contact_email\.$#'
identifier: property.notFound
@@ -3462,6 +3624,12 @@ parameters:
count: 1
path: app/Models/AdvanceSubmission.php
-
message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#'
identifier: class.notFound
count: 3
path: app/Models/Artist.php
-
message: '#^Class App\\Models\\Artist uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -3498,6 +3666,24 @@ parameters:
count: 1
path: app/Models/Artist.php
-
message: '#^Parameter \$activity of method App\\Models\\Artist\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#'
identifier: class.notFound
count: 1
path: app/Models/Artist.php
-
message: '#^Unable to resolve the template type TKey in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Artist.php
-
message: '#^Unable to resolve the template type TValue in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Artist.php
-
message: '#^Class App\\Models\\ArtistContact uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -3522,6 +3708,12 @@ parameters:
count: 1
path: app/Models/ArtistContact.php
-
message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#'
identifier: class.notFound
count: 3
path: app/Models/ArtistEngagement.php
-
message: '#^Class App\\Models\\ArtistEngagement uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -3564,6 +3756,24 @@ parameters:
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Parameter \$activity of method App\\Models\\ArtistEngagement\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#'
identifier: class.notFound
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Unable to resolve the template type TKey in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Unable to resolve the template type TValue in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Class App\\Models\\Company uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -4446,6 +4656,12 @@ parameters:
count: 1
path: app/Models/FormBuilder/FormWebhookDelivery.php
-
message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#'
identifier: class.notFound
count: 3
path: app/Models/Genre.php
-
message: '#^Class App\\Models\\Genre uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -4464,6 +4680,24 @@ parameters:
count: 1
path: app/Models/Genre.php
-
message: '#^Parameter \$activity of method App\\Models\\Genre\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#'
identifier: class.notFound
count: 1
path: app/Models/Genre.php
-
message: '#^Unable to resolve the template type TKey in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Genre.php
-
message: '#^Unable to resolve the template type TValue in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Genre.php
-
message: '#^Class App\\Models\\ImpersonationSession uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -4644,6 +4878,18 @@ parameters:
count: 1
path: app/Models/OrganisationEmailTemplate.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#'
identifier: property.notFound
count: 1
path: app/Models/Performance.php
-
message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#'
identifier: class.notFound
count: 3
path: app/Models/Performance.php
-
message: '#^Class App\\Models\\Performance uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -4668,6 +4914,24 @@ parameters:
count: 1
path: app/Models/Performance.php
-
message: '#^Parameter \$activity of method App\\Models\\Performance\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#'
identifier: class.notFound
count: 1
path: app/Models/Performance.php
-
message: '#^Unable to resolve the template type TKey in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Performance.php
-
message: '#^Unable to resolve the template type TValue in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Performance.php
-
message: '#^Class App\\Models\\Person uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -5154,6 +5418,18 @@ parameters:
count: 1
path: app/Models/ShiftWaitlist.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#'
identifier: property.notFound
count: 1
path: app/Models/Stage.php
-
message: '#^Access to property \$properties on an unknown class App\\Models\\Activity\.$#'
identifier: class.notFound
count: 3
path: app/Models/Stage.php
-
message: '#^Class App\\Models\\Stage uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -5190,6 +5466,24 @@ parameters:
count: 1
path: app/Models/Stage.php
-
message: '#^Parameter \$activity of method App\\Models\\Stage\:\:tapActivity\(\) has invalid type App\\Models\\Activity\.$#'
identifier: class.notFound
count: 1
path: app/Models/Stage.php
-
message: '#^Unable to resolve the template type TKey in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Stage.php
-
message: '#^Unable to resolve the template type TValue in call to function collect$#'
identifier: argument.templateType
count: 1
path: app/Models/Stage.php
-
message: '#^Class App\\Models\\StageDay uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -5508,6 +5802,24 @@ parameters:
count: 1
path: app/Observers/FormBuilder/FormValueObserver.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
count: 2
path: app/Policies/ArtistEngagementPolicy.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Policies/ArtistPolicy.php
-
message: '#^Parameter \#2 \$organisation of method App\\Policies\\ArtistPolicy\:\:canManageArtists\(\) expects App\\Models\\Organisation, Illuminate\\Database\\Eloquent\\Model\|null given\.$#'
identifier: argument.type
count: 3
path: app/Policies/ArtistPolicy.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
@@ -5598,12 +5910,30 @@ parameters:
count: 2
path: app/Policies/FormBuilder/FormTemplatePolicy.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Policies/GenrePolicy.php
-
message: '#^Parameter \#2 \$organisation of method App\\Policies\\GenrePolicy\:\:canManageSettings\(\) expects App\\Models\\Organisation, Illuminate\\Database\\Eloquent\\Model\|null given\.$#'
identifier: argument.type
count: 2
path: app/Policies/GenrePolicy.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
count: 2
path: app/Policies/LocationPolicy.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
count: 2
path: app/Policies/PerformancePolicy.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$event\.$#'
identifier: property.notFound
@@ -5640,12 +5970,90 @@ parameters:
count: 3
path: app/Policies/ShiftPolicy.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
count: 2
path: app/Policies/StagePolicy.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:users\(\)\.$#'
identifier: method.notFound
count: 2
path: app/Policies/TimeSlotPolicy.php
-
message: '#^Parameter \#1 \$date of static method Carbon\\CarbonImmutable\:\:instance\(\) expects DateTimeInterface, string given\.$#'
identifier: argument.type
count: 2
path: app/Rules/Artist/WithinEventBounds.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$handles_buma\.$#'
identifier: property.notFound
count: 1
path: app/Services/Artist/ArtistEngagementService.php
-
message: '#^Cannot call method isPast\(\) on string\.$#'
identifier: method.nonObject
count: 2
path: app/Services/Artist/ArtistEngagementService.php
-
message: '#^Property App\\Models\\ArtistEngagement\:\:\$booking_status \(string\) does not accept App\\Enums\\Artist\\ArtistEngagementStatus\.$#'
identifier: assign.propertyType
count: 2
path: app/Services/Artist/ArtistEngagementService.php
-
message: '#^Property App\\Models\\ArtistEngagement\:\:\$buma_handled_by \(string\) does not accept App\\Enums\\Artist\\BumaHandledBy\:\:BookingAgency\|App\\Enums\\Artist\\BumaHandledBy\:\:Organisation\.$#'
identifier: assign.propertyType
count: 1
path: app/Services/Artist/ArtistEngagementService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#'
identifier: property.notFound
count: 2
path: app/Services/Artist/LaneCascadeService.php
-
message: '#^Parameter \#3 \$bStart of method App\\Services\\Artist\\LaneCascadeService\:\:overlaps\(\) expects DateTimeInterface, string given\.$#'
identifier: argument.type
count: 1
path: app/Services/Artist/LaneCascadeService.php
-
message: '#^Parameter \#4 \$bEnd of method App\\Services\\Artist\\LaneCascadeService\:\:overlaps\(\) expects DateTimeInterface, string given\.$#'
identifier: argument.type
count: 1
path: app/Services/Artist/LaneCascadeService.php
-
message: '#^Property App\\Models\\Performance\:\:\$end_at \(string\) does not accept Carbon\\CarbonImmutable\.$#'
identifier: assign.propertyType
count: 2
path: app/Services/Artist/LaneCascadeService.php
-
message: '#^Property App\\Models\\Performance\:\:\$start_at \(string\) does not accept Carbon\\CarbonImmutable\.$#'
identifier: assign.propertyType
count: 2
path: app/Services/Artist/LaneCascadeService.php
-
message: '#^Parameter \#1 \$date of static method Carbon\\CarbonImmutable\:\:instance\(\) expects DateTimeInterface, string given\.$#'
identifier: argument.type
count: 2
path: app/Services/Artist/LaneResolver.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#'
identifier: property.notFound
count: 1
path: app/Services/Artist/StageDayService.php
-
message: '#^Method App\\Services\\CrowdListService\:\:create\(\) has parameter \$data with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
@@ -7260,6 +7668,18 @@ parameters:
count: 2
path: tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php
-
message: '#^Method Tests\\Feature\\Artist\\BumaVatCalculationTest\:\:compute\(\) has parameter \$attrs with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Feature/Artist/BumaVatCalculationTest.php
-
message: '#^Method Tests\\Feature\\Artist\\BumaVatCalculationTest\:\:compute\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: tests/Feature/Artist/BumaVatCalculationTest.php
-
message: '#^Unable to resolve the template type TKey in call to function collect$#'
identifier: argument.templateType

View File

@@ -337,8 +337,31 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () {
Route::get('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'persons']);
Route::post('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'addPerson']);
Route::delete('crowd-lists/{crowdList}/persons/{person}', [CrowdListController::class, 'removePerson']);
// RFC-TIMETABLE v0.2 — artist domain (Session 2)
// Engagements
Route::apiResource('engagements', \App\Http\Controllers\Api\V1\Artist\ArtistEngagementController::class);
// Stages — specific routes before {stage} wildcard
Route::post('stages/order', [\App\Http\Controllers\Api\V1\Artist\StageController::class, 'reorder']);
Route::put('stages/{stage}/days', [\App\Http\Controllers\Api\V1\Artist\StageController::class, 'replaceDays']);
Route::apiResource('stages', \App\Http\Controllers\Api\V1\Artist\StageController::class);
// Performances
Route::apiResource('performances', \App\Http\Controllers\Api\V1\Artist\PerformanceController::class);
// Timetable move (D18 — guarded by 60s Redis idempotency window per R1)
Route::post('timetable/move', \App\Http\Controllers\Api\V1\Artist\TimetableMoveController::class)
->middleware('idempotency.60s');
});
// RFC-TIMETABLE v0.2 — org-level artist resources (Session 2)
Route::apiResource('artists', \App\Http\Controllers\Api\V1\Artist\ArtistController::class);
Route::post('artists/{artist}/restore', [\App\Http\Controllers\Api\V1\Artist\ArtistController::class, 'restore'])
->withTrashed();
Route::apiResource('genres', \App\Http\Controllers\Api\V1\Artist\GenreController::class)
->except(['show']);
// Form Builder (ARCH-FORM-BUILDER.md)
Route::prefix('forms')->group(function (): void {
// Filter registry

View File

@@ -10,6 +10,13 @@ Artisan::command('inspire', function () {
Schedule::command('invitations:expire')->daily();
// RFC-TIMETABLE v0.2 — demote engagements whose option_expires_at has
// passed back to Draft. Daily at 03:00 Europe/Amsterdam (matches the
// scheduler's nightly window for low-traffic state changes).
Schedule::command('artist:demote-expired-options')
->dailyAt('03:00')
->timezone('Europe/Amsterdam');
// Telescope retention — dev-only (mirrors AppServiceProvider's
// environment gate). 48h is enough for debugging without filling the
// dev database.

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Services\Artist\ArtistEngagementService;
use App\Services\Artist\LaneCascadeService;
use App\Services\Artist\StageDayService;
use App\Services\Artist\StageService;
use Carbon\CarbonImmutable;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class ActivityLogShapeTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private Stage $stage;
private ArtistEngagement $engagement;
private Performance $perf;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->event = Event::factory()->create([
'organisation_id' => $this->org->id,
'start_date' => CarbonImmutable::now()->subDay(),
'end_date' => CarbonImmutable::now()->addDays(30),
]);
$this->stage = Stage::factory()->create(['event_id' => $this->event->id]);
StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]);
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$this->engagement = ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Draft,
'fee_amount' => 1500,
]);
$start = CarbonImmutable::now()->addDays(2)->setTime(20, 0);
$this->perf = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
}
public function test_performance_moved_carries_cascade_props(): void
{
$other = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'start_at' => CarbonImmutable::now()->addDays(2)->setTime(20, 30),
'end_at' => CarbonImmutable::now()->addDays(2)->setTime(21, 30),
'version' => 0,
]);
$start = CarbonImmutable::now()->addDays(2)->setTime(20, 0);
$this->app->make(LaneCascadeService::class)->move(
performance: $this->perf,
targetStage: $this->stage,
start: $start,
end: $start->addHour(),
targetLane: 0,
clientVersion: 0,
);
$entry = Activity::query()
->where('event', 'moved')
->where('subject_id', $this->perf->id)
->latest('id')
->first();
$this->assertNotNull($entry);
$props = $entry->properties->toArray();
$this->assertArrayHasKey('cascade_count', $props);
$this->assertArrayHasKey('cascaded_ids', $props);
$this->assertSame(1, $props['cascade_count']);
$this->assertContains((string) $other->id, $props['cascaded_ids']);
}
public function test_status_changed_distinct_from_cancelled(): void
{
$service = $this->app->make(ArtistEngagementService::class);
$service->transitionStatus($this->engagement, ArtistEngagementStatus::Requested);
$this->assertTrue(
Activity::query()
->where('event', 'status_changed')
->where('subject_id', $this->engagement->id)
->exists(),
);
$this->assertFalse(
Activity::query()
->where('event', 'cancelled')
->where('subject_id', $this->engagement->id)
->exists(),
);
$eng2 = ArtistEngagement::factory()->create([
'artist_id' => Artist::factory()->create(['organisation_id' => $this->org->id])->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
'fee_amount' => 1000,
]);
$service->cancel($eng2);
$this->assertTrue(
Activity::query()
->where('event', 'cancelled')
->where('subject_id', $eng2->id)
->exists(),
);
}
public function test_stage_day_added_emitted(): void
{
$sub = Event::factory()->create([
'organisation_id' => $this->org->id,
'parent_event_id' => $this->event->id,
]);
$this->app->make(StageDayService::class)->replaceDays(
$this->stage,
[$this->event->id, $sub->id],
);
$this->assertTrue(
Activity::query()
->where('event', 'day_added')
->where('subject_id', $this->stage->id)
->whereJsonContains('properties->event_id', $sub->id)
->exists(),
);
}
public function test_stage_reordered_emitted_on_event_subject(): void
{
$other = Stage::factory()->create(['event_id' => $this->event->id]);
$this->app->make(StageService::class)->reorder($this->event, [$other->id, $this->stage->id]);
$this->assertTrue(
Activity::query()
->where('event', 'reordered')
->where('subject_id', $this->event->id)
->exists(),
);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class ArtistControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Organisation $otherOrg;
private User $orgAdmin;
private User $programManager;
private User $outsider;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->otherOrg = Organisation::factory()->create();
$this->orgAdmin = User::factory()->create();
$this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
$this->programManager = User::factory()->create();
$this->org->users()->attach($this->programManager, ['role' => 'program_manager']);
$this->outsider = User::factory()->create();
$this->otherOrg->users()->attach($this->outsider, ['role' => 'org_admin']);
}
public function test_index_lists_artists_for_member(): void
{
Artist::factory()->count(3)->create(['organisation_id' => $this->org->id]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->org->id}/artists");
$response->assertOk();
$this->assertCount(3, $response->json('data'));
}
public function test_index_unauthenticated_returns_401(): void
{
$this->getJson("/api/v1/organisations/{$this->org->id}/artists")->assertUnauthorized();
}
public function test_outsider_cannot_view_other_org_artists(): void
{
Artist::factory()->create(['organisation_id' => $this->org->id]);
Sanctum::actingAs($this->outsider);
$this->getJson("/api/v1/organisations/{$this->org->id}/artists")->assertForbidden();
}
public function test_program_manager_can_create(): void
{
Sanctum::actingAs($this->programManager);
$response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists", [
'name' => 'Headhunterz',
]);
$response->assertCreated();
$this->assertSame('Headhunterz', $response->json('data.name'));
}
public function test_duplicate_name_returns_409_with_existing_id(): void
{
$existing = Artist::factory()->create([
'organisation_id' => $this->org->id,
'name' => 'Devin Wild',
]);
Sanctum::actingAs($this->programManager);
$response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists", [
'name' => 'devin wild',
]);
$response->assertStatus(409);
$this->assertSame((string) $existing->id, (string) $response->json('errors.duplicate_artist_id'));
}
public function test_destroy_blocked_with_active_engagement(): void
{
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$event = Event::factory()->create(['organisation_id' => $this->org->id]);
ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
]);
Sanctum::actingAs($this->orgAdmin);
$this->deleteJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}")
->assertForbidden();
$this->assertDatabaseHas('artists', ['id' => $artist->id, 'deleted_at' => null]);
}
public function test_destroy_with_only_terminal_engagements_succeeds(): void
{
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$event = Event::factory()->create(['organisation_id' => $this->org->id]);
ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $event->id,
'booking_status' => ArtistEngagementStatus::Cancelled,
]);
Sanctum::actingAs($this->orgAdmin);
$this->deleteJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}")
->assertNoContent();
}
public function test_restore_brings_back_soft_deleted_artist(): void
{
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$artist->delete();
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/organisations/{$this->org->id}/artists/{$artist->id}/restore");
$response->assertOk();
$this->assertDatabaseHas('artists', ['id' => $artist->id, 'deleted_at' => null]);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class ArtistEngagementControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private User $orgAdmin;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->orgAdmin = User::factory()->create();
$this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
$this->event = Event::factory()->create(['organisation_id' => $this->org->id]);
$this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
}
private function url(string $tail = ''): string
{
return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/engagements{$tail}";
}
public function test_index_returns_engagements(): void
{
$a = Artist::factory()->create(['organisation_id' => $this->org->id]);
$b = Artist::factory()->create(['organisation_id' => $this->org->id]);
ArtistEngagement::factory()->create(['artist_id' => $a->id, 'event_id' => $this->event->id]);
ArtistEngagement::factory()->create(['artist_id' => $b->id, 'event_id' => $this->event->id]);
Sanctum::actingAs($this->orgAdmin);
$this->getJson($this->url())->assertOk()->assertJsonCount(2, 'data');
}
public function test_create_engagement(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson($this->url(), [
'artist_id' => $this->artist->id,
'booking_status' => ArtistEngagementStatus::Draft->value,
]);
$response->assertCreated();
}
public function test_create_with_invalid_status_transition_returns_422(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson($this->url(), [
'artist_id' => $this->artist->id,
'booking_status' => ArtistEngagementStatus::Option->value,
// Missing option_expires_at — service should refuse
]);
$response->assertStatus(422);
}
public function test_update_status_transition(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Draft,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->patchJson($this->url("/{$eng->id}"), [
'booking_status' => ArtistEngagementStatus::Requested->value,
]);
$response->assertOk();
$this->assertSame(
ArtistEngagementStatus::Requested,
$eng->refresh()->booking_status,
);
}
public function test_destroy_soft_deletes(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
]);
Sanctum::actingAs($this->orgAdmin);
$this->deleteJson($this->url("/{$eng->id}"))->assertNoContent();
$this->assertSoftDeleted('artist_engagements', ['id' => $eng->id]);
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Exceptions\Artist\InvalidStatusTransitionException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Services\Artist\ArtistEngagementService;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ArtistEngagementStateMachineTest extends TestCase
{
use RefreshDatabase;
private ArtistEngagementService $service;
private Organisation $org;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->service = $this->app->make(ArtistEngagementService::class);
$this->org = Organisation::factory()->create();
$this->event = Event::factory()->create(['organisation_id' => $this->org->id]);
$this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
}
public function test_rejected_is_terminal(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Rejected,
]);
$this->expectException(InvalidStatusTransitionException::class);
$this->service->transitionStatus($eng, ArtistEngagementStatus::Contracted);
}
public function test_declined_is_terminal(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Declined,
]);
$this->expectException(InvalidStatusTransitionException::class);
$this->service->transitionStatus($eng, ArtistEngagementStatus::Draft);
}
public function test_cancelled_is_terminal(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Cancelled,
]);
$this->expectException(InvalidStatusTransitionException::class);
$this->service->transitionStatus($eng, ArtistEngagementStatus::Confirmed);
}
public function test_option_requires_future_expiry(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Draft,
'option_expires_at' => null,
]);
$this->expectException(InvalidStatusTransitionException::class);
$this->service->transitionStatus($eng, ArtistEngagementStatus::Option);
}
public function test_option_with_past_expiry_blocked(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Draft,
'option_expires_at' => now()->subHour(),
]);
$this->expectException(InvalidStatusTransitionException::class);
$this->service->transitionStatus($eng, ArtistEngagementStatus::Option);
}
public function test_contracted_requires_fee(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
'fee_amount' => null,
]);
$this->expectException(InvalidStatusTransitionException::class);
$this->service->transitionStatus($eng, ArtistEngagementStatus::Contracted);
}
public function test_happy_path_sequence_permitted(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Draft,
'fee_amount' => 1500.00,
'option_expires_at' => now()->addDays(14),
]);
foreach ([
ArtistEngagementStatus::Requested,
ArtistEngagementStatus::Option,
ArtistEngagementStatus::Offered,
ArtistEngagementStatus::Confirmed,
ArtistEngagementStatus::Contracted,
] as $next) {
$this->service->transitionStatus($eng, $next);
$this->assertSame($next, $eng->refresh()->booking_status);
}
}
public function test_cancel_transitions_and_soft_deletes(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
]);
$this->service->cancel($eng);
$reloaded = ArtistEngagement::withoutGlobalScopes()->withTrashed()->find($eng->id);
$this->assertNotNull($reloaded);
$this->assertSame(ArtistEngagementStatus::Cancelled, $reloaded->booking_status);
$this->assertNotNull($reloaded->deleted_at);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Tests\TestCase;
final class ArtistPolicyTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Organisation $otherOrg;
private User $orgAdmin;
private User $programManager;
private User $crossTenantAdmin;
private User $superAdmin;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->otherOrg = Organisation::factory()->create();
$this->orgAdmin = User::factory()->create();
$this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
$this->programManager = User::factory()->create();
$this->org->users()->attach($this->programManager, ['role' => 'program_manager']);
$this->crossTenantAdmin = User::factory()->create();
$this->otherOrg->users()->attach($this->crossTenantAdmin, ['role' => 'org_admin']);
$this->superAdmin = User::factory()->create();
$this->superAdmin->assignRole('super_admin');
$this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
}
public function test_org_admin_can_create(): void
{
$this->assertTrue(Gate::forUser($this->orgAdmin)->allows('create', [Artist::class, $this->org]));
}
public function test_program_manager_can_update(): void
{
$this->assertTrue(Gate::forUser($this->programManager)->allows('update', $this->artist));
}
public function test_cross_tenant_admin_denied(): void
{
$this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('view', $this->artist));
$this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('update', $this->artist));
$this->assertFalse(Gate::forUser($this->crossTenantAdmin)->allows('delete', $this->artist));
}
public function test_super_admin_bypass(): void
{
$this->assertTrue(Gate::forUser($this->superAdmin)->allows('view', $this->artist));
$this->assertTrue(Gate::forUser($this->superAdmin)->allows('update', $this->artist));
}
public function test_delete_blocked_with_active_engagement(): void
{
$event = Event::factory()->create(['organisation_id' => $this->org->id]);
ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
]);
$this->assertFalse(Gate::forUser($this->orgAdmin)->allows('delete', $this->artist));
}
public function test_delete_allowed_with_only_terminal_engagements(): void
{
$event = Event::factory()->create(['organisation_id' => $this->org->id]);
ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $event->id,
'booking_status' => ArtistEngagementStatus::Cancelled,
]);
$this->assertTrue(Gate::forUser($this->orgAdmin)->allows('delete', $this->artist));
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\BumaHandledBy;
use App\Http\Resources\Api\V1\Artist\ArtistEngagementResource;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
/**
* RFC v0.2 D26 Buma + VAT computation.
*/
final class BumaVatCalculationTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->event = Event::factory()->create(['organisation_id' => $this->org->id]);
$this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
}
private function compute(array $attrs): array
{
$eng = ArtistEngagement::factory()->create(array_merge([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
], $attrs));
$req = Request::create('/');
$payload = (new ArtistEngagementResource($eng))->toArray($req);
return $payload['computed'];
}
public function test_organisation_handles_buma_includes_buma_in_vat_grondslag(): void
{
$c = $this->compute([
'fee_amount' => 1000.00,
'buma_applicable' => true,
'buma_percentage' => 7.00,
'buma_handled_by' => BumaHandledBy::Organisation,
'vat_applicable' => true,
'vat_percentage' => 21.00,
'deal_breakdown' => [],
]);
$this->assertSame(70.0, $c['buma_amount']);
$this->assertSame(1070.0, $c['vat_grondslag']);
$this->assertSame(224.7, $c['vat_amount']);
$this->assertSame(1294.7, $c['total_cost']);
}
public function test_booking_agency_handles_buma_excludes_from_vat_grondslag(): void
{
$c = $this->compute([
'fee_amount' => 1000.00,
'buma_applicable' => true,
'buma_percentage' => 7.00,
'buma_handled_by' => BumaHandledBy::BookingAgency,
'vat_applicable' => true,
'vat_percentage' => 21.00,
'deal_breakdown' => [],
]);
$this->assertSame(0.0, $c['buma_amount']);
$this->assertSame(1000.0, $c['vat_grondslag']);
$this->assertSame(210.0, $c['vat_amount']);
$this->assertSame(1210.0, $c['total_cost']);
}
public function test_not_applicable_buma_yields_zero_buma(): void
{
$c = $this->compute([
'fee_amount' => 1000.00,
'buma_applicable' => false,
'buma_percentage' => 7.00,
'buma_handled_by' => BumaHandledBy::NotApplicable,
'vat_applicable' => true,
'vat_percentage' => 21.00,
'deal_breakdown' => [],
]);
$this->assertSame(0.0, $c['buma_amount']);
$this->assertSame(1000.0, $c['vat_grondslag']);
}
public function test_vat_disabled_yields_zero_vat(): void
{
$c = $this->compute([
'fee_amount' => 1000.00,
'buma_applicable' => true,
'buma_percentage' => 7.00,
'buma_handled_by' => BumaHandledBy::Organisation,
'vat_applicable' => false,
'vat_percentage' => 21.00,
'deal_breakdown' => [],
]);
$this->assertSame(70.0, $c['buma_amount']);
$this->assertSame(0.0, $c['vat_amount']);
}
public function test_breakdown_summed_into_total_cost(): void
{
$c = $this->compute([
'fee_amount' => 500.00,
'buma_applicable' => false,
'buma_handled_by' => BumaHandledBy::NotApplicable,
'vat_applicable' => false,
'deal_breakdown' => [
['label' => 'Hospitality', 'amount' => 50.00],
['label' => 'Hotel', 'amount' => 120.00],
],
]);
$this->assertSame(170.0, $c['breakdown_total']);
$this->assertSame(670.0, $c['total_cost']);
}
public function test_zero_fee_yields_zero_components(): void
{
$c = $this->compute([
'fee_amount' => 0,
'buma_applicable' => true,
'buma_percentage' => 7.00,
'buma_handled_by' => BumaHandledBy::Organisation,
'vat_applicable' => true,
'vat_percentage' => 21.00,
'deal_breakdown' => [],
]);
$this->assertSame(0.0, $c['buma_amount']);
$this->assertSame(0.0, $c['vat_amount']);
$this->assertSame(0.0, $c['total_cost']);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class DemoteExpiredOptionsTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private Artist $artist;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->event = Event::factory()->create(['organisation_id' => $this->org->id]);
$this->artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
}
public function test_expired_option_demoted_to_draft(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Option,
'option_expires_at' => now()->subMinute(),
]);
$this->artisan('artist:demote-expired-options')->assertSuccessful();
$this->assertSame(
ArtistEngagementStatus::Draft,
$eng->refresh()->booking_status,
);
$this->assertTrue(
Activity::query()
->where('subject_type', $eng->getMorphClass())
->where('subject_id', $eng->id)
->where('event', 'option_expired')
->exists(),
);
}
public function test_future_option_untouched(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Option,
'option_expires_at' => now()->addHour(),
]);
$this->artisan('artist:demote-expired-options')->assertSuccessful();
$this->assertSame(
ArtistEngagementStatus::Option,
$eng->refresh()->booking_status,
);
}
public function test_non_option_status_untouched(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
'option_expires_at' => now()->subDay(),
]);
$this->artisan('artist:demote-expired-options')->assertSuccessful();
$this->assertSame(
ArtistEngagementStatus::Confirmed,
$eng->refresh()->booking_status,
);
}
public function test_running_twice_writes_only_one_option_expired_entry(): void
{
$eng = ArtistEngagement::factory()->create([
'artist_id' => $this->artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Option,
'option_expires_at' => now()->subMinute(),
]);
$this->artisan('artist:demote-expired-options')->assertSuccessful();
$this->artisan('artist:demote-expired-options')->assertSuccessful();
$count = Activity::query()
->where('subject_type', $eng->getMorphClass())
->where('subject_id', $eng->id)
->where('event', 'option_expired')
->count();
$this->assertSame(1, $count);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Http\Middleware\IdempotencyKey60sRedis;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;
final class IdempotencyKey60sRedisTest extends TestCase
{
public function test_missing_header_returns_400(): void
{
$middleware = new IdempotencyKey60sRedis;
$request = Request::create('/x', 'POST');
$response = $middleware->handle($request, fn () => response('ok'));
$this->assertSame(400, $response->getStatusCode());
$this->assertStringContainsString('idempotency_key_required', (string) $response->getContent());
}
public function test_first_request_caches_and_passes_through(): void
{
Cache::flush();
$middleware = new IdempotencyKey60sRedis;
$request = Request::create('/x', 'POST');
$request->headers->set('Idempotency-Key', 'abc-123');
$count = 0;
$response = $middleware->handle($request, function () use (&$count): Response {
$count++;
return response()->json(['ok' => true]);
});
$this->assertSame(200, $response->getStatusCode());
$this->assertSame(1, $count);
}
public function test_replayed_request_returns_cached_body_with_replayed_header(): void
{
Cache::flush();
$middleware = new IdempotencyKey60sRedis;
$request1 = Request::create('/x', 'POST');
$request1->headers->set('Idempotency-Key', 'replay-key');
$count = 0;
$middleware->handle($request1, function () use (&$count) {
$count++;
return response()->json(['result' => 'one']);
});
$request2 = Request::create('/x', 'POST');
$request2->headers->set('Idempotency-Key', 'replay-key');
$response2 = $middleware->handle($request2, function () use (&$count) {
$count++;
return response()->json(['result' => 'two']);
});
$this->assertSame(1, $count, 'inner handler should not run on replay');
$this->assertSame('true', $response2->headers->get('Idempotency-Replayed'));
$this->assertStringContainsString('one', (string) $response2->getContent());
}
public function test_failed_response_not_cached(): void
{
Cache::flush();
$middleware = new IdempotencyKey60sRedis;
$request1 = Request::create('/x', 'POST');
$request1->headers->set('Idempotency-Key', 'fail-key');
$middleware->handle($request1, fn () => response()->json(['x' => 1], 422));
$request2 = Request::create('/x', 'POST');
$request2->headers->set('Idempotency-Key', 'fail-key');
$count = 0;
$middleware->handle($request2, function () use (&$count) {
$count++;
return response()->json(['x' => 2]);
});
$this->assertSame(1, $count, 'failed responses should not be cached for replay');
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Exceptions\Artist\VersionMismatchException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Services\Artist\LaneCascadeService;
use Carbon\CarbonImmutable;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class LaneCascadeServiceTest extends TestCase
{
use RefreshDatabase;
private LaneCascadeService $service;
private Organisation $org;
private Event $event;
private Stage $stage;
private ArtistEngagement $engagement;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->service = $this->app->make(LaneCascadeService::class);
$this->org = Organisation::factory()->create();
$this->event = Event::factory()->create([
'organisation_id' => $this->org->id,
'start_date' => CarbonImmutable::now()->subDay(),
'end_date' => CarbonImmutable::now()->addDays(30),
]);
$this->stage = Stage::factory()->create(['event_id' => $this->event->id]);
StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]);
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$this->engagement = ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $this->event->id,
]);
}
public function test_simple_move_no_overlap_succeeds(): void
{
$perf = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'version' => 0,
]);
$start = CarbonImmutable::now()->addDays(2)->setTime(20, 0);
$result = $this->service->move(
performance: $perf,
targetStage: $this->stage,
start: $start,
end: $start->addHour(),
targetLane: 0,
clientVersion: 0,
);
$this->assertSame([], $result->cascaded);
$this->assertGreaterThan(0, $result->moved->version);
}
public function test_overlap_cascades_existing_to_higher_lane(): void
{
$start = CarbonImmutable::now()->addDays(3)->setTime(22, 0);
$existing = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
$other = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => null, // parked
'lane' => 0,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
$result = $this->service->move(
performance: $other,
targetStage: $this->stage,
start: $start->addMinutes(15),
end: $start->addMinutes(75),
targetLane: 0,
clientVersion: 0,
);
$this->assertCount(1, $result->cascaded);
$this->assertSame((string) $existing->id, (string) $result->cascaded[0]->id);
$this->assertSame(1, (int) $result->cascaded[0]->lane);
}
public function test_version_mismatch_throws(): void
{
$perf = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'version' => 5,
]);
$this->expectException(VersionMismatchException::class);
$this->service->move(
performance: $perf,
targetStage: $this->stage,
start: CarbonImmutable::parse((string) $perf->start_at),
end: CarbonImmutable::parse((string) $perf->end_at),
targetLane: 0,
clientVersion: 4,
);
}
public function test_park_clears_stage_id(): void
{
$perf = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 2,
'version' => 0,
]);
$result = $this->service->move(
performance: $perf,
targetStage: null,
start: null,
end: null,
targetLane: null,
clientVersion: 0,
);
$this->assertNull($result->moved->stage_id);
$this->assertSame([], $result->cascaded);
$this->assertSame(2, (int) $result->moved->lane);
}
public function test_unpark_to_stage_succeeds(): void
{
$perf = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => null,
'lane' => 0,
'version' => 0,
]);
$start = CarbonImmutable::now()->addDays(4)->setTime(21, 0);
$result = $this->service->move(
performance: $perf,
targetStage: $this->stage,
start: $start,
end: $start->addHour(),
targetLane: 0,
clientVersion: 0,
);
$this->assertSame((string) $this->stage->id, (string) $result->moved->stage_id);
}
public function test_move_with_cascade_writes_exactly_one_activity_entry_on_moved_subject_and_zero_on_peers(): void
{
$start = CarbonImmutable::now()->addDays(5)->setTime(20, 0);
$p2 = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
$p1 = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 1,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
// Clear setup-time activity rows so we measure only the move.
Activity::query()->delete();
$this->service->move(
performance: $p1,
targetStage: $this->stage,
start: $start,
end: $start->addHour(),
targetLane: 0,
clientVersion: 0,
);
$movedEntries = Activity::query()
->where('subject_type', $p1->getMorphClass())
->where('subject_id', $p1->id)
->get();
$this->assertCount(1, $movedEntries, 'Expected exactly one activity entry for moved performance');
$this->assertSame('moved', $movedEntries->first()->event);
$this->assertSame(1, $movedEntries->first()->properties['cascade_count']);
$this->assertContains((string) $p2->id, $movedEntries->first()->properties['cascaded_ids']);
$cascadedEntries = Activity::query()
->where('subject_type', $p2->getMorphClass())
->where('subject_id', $p2->id)
->get();
$this->assertCount(0, $cascadedEntries, 'Expected zero activity entries on cascade-bumped peer');
}
public function test_park_writes_single_parked_activity_entry(): void
{
$perf = Performance::factory()->create([
'engagement_id' => $this->engagement->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'version' => 0,
]);
Activity::query()->delete();
$this->service->move(
performance: $perf,
targetStage: null,
start: null,
end: null,
targetLane: null,
clientVersion: 0,
);
$entries = Activity::query()
->where('subject_type', $perf->getMorphClass())
->where('subject_id', $perf->id)
->get();
$this->assertCount(1, $entries, 'Expected exactly one parked entry');
$this->assertSame('parked', $entries->first()->event);
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class StageControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private User $orgAdmin;
private Event $event;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->orgAdmin = User::factory()->create();
$this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
$this->event = Event::factory()->create(['organisation_id' => $this->org->id]);
}
private function url(string $tail = ''): string
{
return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages{$tail}";
}
public function test_create_stage(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson($this->url(), [
'name' => 'Mainstage',
'color' => '#ff0000',
'capacity' => 5000,
]);
$response->assertCreated();
$this->assertSame('Mainstage', $response->json('data.name'));
}
public function test_create_unique_name_per_event(): void
{
Stage::factory()->create(['event_id' => $this->event->id, 'name' => 'Hardstyle']);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson($this->url(), [
'name' => 'Hardstyle',
'color' => '#000000',
]);
$response->assertStatus(422);
}
public function test_destroy_cascade_parks_performances(): void
{
$stage = Stage::factory()->create(['event_id' => $this->event->id]);
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$eng = ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $this->event->id,
]);
$perf = Performance::factory()->create([
'engagement_id' => $eng->id,
'event_id' => $this->event->id,
'stage_id' => $stage->id,
]);
Sanctum::actingAs($this->orgAdmin);
$this->deleteJson($this->url("/{$stage->id}"))->assertOk();
$perf->refresh();
$this->assertNull($perf->stage_id);
}
public function test_reorder_updates_sort_order(): void
{
$a = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 0]);
$b = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 1]);
$c = Stage::factory()->create(['event_id' => $this->event->id, 'sort_order' => 2]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson($this->url('/order'), [
'stage_ids' => [$c->id, $a->id, $b->id],
]);
$response->assertOk();
$this->assertSame(0, (int) $c->fresh()->sort_order);
$this->assertSame(1, (int) $a->fresh()->sort_order);
$this->assertSame(2, (int) $b->fresh()->sort_order);
}
public function test_reorder_rejects_partial_permutation(): void
{
$a = Stage::factory()->create(['event_id' => $this->event->id]);
Stage::factory()->create(['event_id' => $this->event->id]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson($this->url('/order'), ['stage_ids' => [$a->id]]);
$response->assertStatus(422);
}
public function test_replace_days_orphans_performances_returns_409(): void
{
$stage = Stage::factory()->create(['event_id' => $this->event->id]);
StageDay::query()->create(['stage_id' => $stage->id, 'event_id' => $this->event->id]);
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$eng = ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
]);
Performance::factory()->create([
'engagement_id' => $eng->id,
'event_id' => $this->event->id,
'stage_id' => $stage->id,
]);
Sanctum::actingAs($this->orgAdmin);
// Build a sub-event (different event_id) to replace days with
$other = Event::factory()->create([
'organisation_id' => $this->org->id,
'parent_event_id' => $this->event->id,
]);
$response = $this->putJson("/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages/{$stage->id}/days", [
'event_ids' => [$other->id],
]);
$response->assertStatus(409);
$this->assertSame('orphaned_performances', $response->json('errors.conflict'));
}
public function test_replace_days_with_force_orphan_succeeds(): void
{
$stage = Stage::factory()->create(['event_id' => $this->event->id]);
StageDay::query()->create(['stage_id' => $stage->id, 'event_id' => $this->event->id]);
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$eng = ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $this->event->id,
'booking_status' => ArtistEngagementStatus::Confirmed,
]);
Performance::factory()->create([
'engagement_id' => $eng->id,
'event_id' => $this->event->id,
'stage_id' => $stage->id,
]);
$other = Event::factory()->create([
'organisation_id' => $this->org->id,
'parent_event_id' => $this->event->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson(
"/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/stages/{$stage->id}/days?force_orphan=true",
['event_ids' => [$other->id]],
);
$response->assertOk();
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Performance;
use App\Models\Stage;
use App\Models\StageDay;
use App\Models\User;
use Carbon\CarbonImmutable;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class TimetableMoveControllerTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private User $orgAdmin;
private Event $event;
private Stage $stage;
private Performance $perf;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->orgAdmin = User::factory()->create();
$this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
$this->event = Event::factory()->create([
'organisation_id' => $this->org->id,
'start_date' => CarbonImmutable::now()->subDay(),
'end_date' => CarbonImmutable::now()->addDays(30),
]);
$this->stage = Stage::factory()->create(['event_id' => $this->event->id]);
StageDay::query()->create(['stage_id' => $this->stage->id, 'event_id' => $this->event->id]);
$artist = Artist::factory()->create(['organisation_id' => $this->org->id]);
$eng = ArtistEngagement::factory()->create([
'artist_id' => $artist->id,
'event_id' => $this->event->id,
]);
$start = CarbonImmutable::now()->addDays(2)->setTime(20, 0);
$this->perf = Performance::factory()->create([
'engagement_id' => $eng->id,
'event_id' => $this->event->id,
'stage_id' => $this->stage->id,
'lane' => 0,
'start_at' => $start,
'end_at' => $start->addHour(),
'version' => 0,
]);
}
private function url(): string
{
return "/api/v1/organisations/{$this->org->id}/events/{$this->event->id}/timetable/move";
}
public function test_move_succeeds_with_idempotency_key(): void
{
Sanctum::actingAs($this->orgAdmin);
$newStart = CarbonImmutable::parse((string) $this->perf->start_at)->addHour();
$response = $this->postJson(
$this->url(),
[
'performance_id' => $this->perf->id,
'target_stage_id' => $this->stage->id,
'target_start_at' => $newStart->format('Y-m-d H:i:s'),
'target_end_at' => $newStart->addHour()->format('Y-m-d H:i:s'),
'target_lane' => 0,
'version' => 0,
],
['Idempotency-Key' => 'test-1'],
);
$response->assertOk();
}
public function test_move_without_idempotency_key_returns_400(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson($this->url(), [
'performance_id' => $this->perf->id,
'target_stage_id' => $this->stage->id,
'target_start_at' => '2026-07-10 22:00:00',
'target_end_at' => '2026-07-10 23:00:00',
'target_lane' => 0,
'version' => 0,
]);
$response->assertStatus(400);
}
public function test_version_mismatch_returns_409(): void
{
Sanctum::actingAs($this->orgAdmin);
$newStart = CarbonImmutable::parse((string) $this->perf->start_at)->addHour();
$response = $this->postJson(
$this->url(),
[
'performance_id' => $this->perf->id,
'target_stage_id' => $this->stage->id,
'target_start_at' => $newStart->format('Y-m-d H:i:s'),
'target_end_at' => $newStart->addHour()->format('Y-m-d H:i:s'),
'target_lane' => 0,
'version' => 99,
],
['Idempotency-Key' => 'test-2'],
);
$response->assertStatus(409);
$this->assertSame('version_mismatch', $response->json('errors.conflict'));
}
}

View File

@@ -700,6 +700,17 @@ Session 1 surfaced deze drift; Step 7 reduceerde tot een in-place rewrite van
**Wat:** Bij de eerstvolgende RFC v0.2 amendement, vervang of verwijder de
`ARCH-PLANNED-MODULES.md` cross-references in §1 en §15. Dit is geen blocker
voor implementatie van Sessions 26.
**Aanvulling Session 2 (2026-05-08):** RFC §9 noemt vier permission-strings
(`events.view_program`, `events.manage_program`, `organisations.manage_artists`,
`organisations.manage_settings`). De implementatie-keuze (Phase A Option B)
bindt deze permissions aan Spatie roles in plaats van aan `Permission`-rijen,
omdat de bestaande codebase rolgebaseerd is en migratie naar fine-grained
permissions cross-cutting is. De docblocks van `ArtistPolicy`,
`ArtistEngagementPolicy`, `StagePolicy`, `PerformancePolicy` en `GenrePolicy`
documenteren de exacte mapping. Cross-cutting migratie wordt gevolgd onder
`AUTH-PERMISSIONS-MIGRATION` (zie hieronder).
**Prioriteit:** Laag — documentatie-hygiëne, niet code.
---
@@ -725,6 +736,99 @@ de divergentie totdat een legitieme amendement langskomt.
---
### EVENT-START-END-TIME
`events` table currently has `start_date`/`end_date` (date type), which
forces day-boundary semantics in `WithinEventBounds` and similar checks.
For festivals running past midnight or with intentionally non-24h
operating windows, a `start_time`/`end_time` pair (or a unified
`start_at`/`end_at` datetime) on the `events` table would let:
- the timetable viewport (Session 4 frontend) honour real event hours
instead of always showing 00:00 → 23:59
- boundary checks like `WithinEventBounds` reject performances that
run past the event's actual close time, even within the same date
**Why not now:**
- Cross-cutting schema change touching the `events` table — used by
30+ modules across the codebase
- Out of scope for the Artist Timetable sprint per Charter §2 (no
opportunistic feature-creep)
- Sub-events absorb the granularity in 90% of cases via Performance
datetimes
**When:**
- Session 4 frontend timetable viewport reveals concrete UX gaps from
date-only event bounds
- OR a customer onboards with a non-day-aligned schedule (e.g. a club
with a 22:00 → 06:00 nightly window)
**Surfaced during:** Session 2 review of
`app/Rules/Artist/WithinEventBounds.php`, which uses
`startOfDay()`/`endOfDay()` to bridge the date-vs-datetime gap. That
bridge is correct given current schema; this ticket is about the
schema, not the rule.
**Prioriteit:** Middel — works today; upgrade is feature-not-bug.
---
### AUTH-PERMISSIONS-MIGRATION — Migrate alle policies van hasRole() naar hasPermissionTo()
**Aanleiding:** Crewli gebruikt vandaag uitsluitend Spatie *roles*; geen
`Permission`-rijen worden geseed en geen policy roept `hasPermissionTo()`
of `Gate::can()` tegen permission-strings aan. RFC-TIMETABLE v0.2 §9
beschrijft de toegangscontrole in termen van permission-strings
(`events.manage_program`, etc.); Phase A van Session 2 (2026-05-08)
besloot Option B — de permission-strings worden in policy-docblocks
gedocumenteerd en role-based geautoriseerd. Een hybride aanpak (perms
seeden maar niet gebruiken) werd afgewezen omdat dat strings creëert
zonder source-of-truth-status.
**Wat:** Eén dedicated cross-cutting sprint die ALLE policies (niet
alleen Artist-domein) overzet van `hasRole()` naar `hasPermissionTo()`.
Inclusief:
- `PermissionSeeder` voor de complete set permissions die we vandaag
via roles uitdrukken (per-domein audit van bestaande policies)
- Policy-by-policy refactor met behoud van semantiek
- Policy-tests bijwerken (bestaande tests gebruiken role-strings)
- Documentatie in `dev-docs/CLAUDE.md` (`Roles and permissions`-blok)
**Trigger:** Klant- of charter-vereiste — een specifieke gebruiker
moet wel X kunnen maar niet Y, en X+Y delen vandaag dezelfde rol.
Niet: interne preferentie of "het is netter".
**Reference:** Session 2 Phase A (2026-05-08) Option B beslissing;
`feedback_authorization_pattern` user-memory (intent-only — niet in
auto-memory geschreven door file-protect hook).
**Prioriteit:** Laag — wachten op concrete operationele behoefte.
---
### ART-DEMOTE-NOTIFICATION — Notify project-leader on option-expiry demotion
**Aanleiding:** RFC-TIMETABLE v0.2 noemt notificatie van de project-leader
wanneer een Option afloopt en automatisch gedemoteerd wordt naar Draft
(via de `artist:demote-expired-options` daily command, Session 2 Step 10).
Het notificatie-framework landt pas post-Accreditation; daarom schrijft
de Session 2 command alleen een `option_expired` activity-log entry, geen
e-mail.
**Wat:** Wanneer notification-framework live is, hook het in de command
in: na elke `transitionStatus()` succes een notificatie naar de project-
leader (en optioneel de `program_manager` rol op het evenement). Houd
rekening met aggregatie als veel Options tegelijk verlopen — niet één
mail per Option.
**Reference:** Session 2 commit `feat(timetable): DemoteExpiredOptions
scheduled command`; `app/Console/Commands/Artist/DemoteExpiredOptions.php`.
**Prioriteit:** Medium — wachten op notification-framework, maar wel een
zichtbare gap voor program managers tot dan.
---
### TECH-01 — Bestaande tests bijwerken na festival/event refactor
**Aanleiding:** Na toevoegen parent_event_id worden bestaande tests