Compare commits
10 Commits
9e94ab78d8
...
5ab68ddbb3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ab68ddbb3 | |||
| 70431fb836 | |||
| bc7d3fcbee | |||
| bdb379f55f | |||
| 996dedc11d | |||
| 5c1faf2061 | |||
| 609280d061 | |||
| 0f9d0bdb4e | |||
| 32da6b656d | |||
| 546f121ee8 |
79
api/app/Console/Commands/Artist/DemoteExpiredOptions.php
Normal file
79
api/app/Console/Commands/Artist/DemoteExpiredOptions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
109
api/app/Http/Controllers/Api/V1/Artist/ArtistController.php
Normal file
109
api/app/Http/Controllers/Api/V1/Artist/ArtistController.php
Normal 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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
70
api/app/Http/Controllers/Api/V1/Artist/GenreController.php
Normal file
70
api/app/Http/Controllers/Api/V1/Artist/GenreController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
102
api/app/Http/Controllers/Api/V1/Artist/PerformanceController.php
Normal file
102
api/app/Http/Controllers/Api/V1/Artist/PerformanceController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
131
api/app/Http/Controllers/Api/V1/Artist/StageController.php
Normal file
131
api/app/Http/Controllers/Api/V1/Artist/StageController.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
api/app/Http/Middleware/IdempotencyKey60sRedis.php
Normal file
76
api/app/Http/Middleware/IdempotencyKey60sRedis.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
177
api/tests/Feature/Artist/ActivityLogShapeTest.php
Normal file
177
api/tests/Feature/Artist/ActivityLogShapeTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
api/tests/Feature/Artist/ArtistControllerTest.php
Normal file
145
api/tests/Feature/Artist/ArtistControllerTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
115
api/tests/Feature/Artist/ArtistEngagementControllerTest.php
Normal file
115
api/tests/Feature/Artist/ArtistEngagementControllerTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
153
api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php
Normal file
153
api/tests/Feature/Artist/ArtistEngagementStateMachineTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
105
api/tests/Feature/Artist/ArtistPolicyTest.php
Normal file
105
api/tests/Feature/Artist/ArtistPolicyTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
155
api/tests/Feature/Artist/BumaVatCalculationTest.php
Normal file
155
api/tests/Feature/Artist/BumaVatCalculationTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
116
api/tests/Feature/Artist/DemoteExpiredOptionsTest.php
Normal file
116
api/tests/Feature/Artist/DemoteExpiredOptionsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
98
api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php
Normal file
98
api/tests/Feature/Artist/IdempotencyKey60sRedisTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
269
api/tests/Feature/Artist/LaneCascadeServiceTest.php
Normal file
269
api/tests/Feature/Artist/LaneCascadeServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
186
api/tests/Feature/Artist/StageControllerTest.php
Normal file
186
api/tests/Feature/Artist/StageControllerTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
131
api/tests/Feature/Artist/TimetableMoveControllerTest.php
Normal file
131
api/tests/Feature/Artist/TimetableMoveControllerTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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 2–6.
|
||||
|
||||
**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
|
||||
|
||||
Reference in New Issue
Block a user