feat: fase 2 backend — crowd types, persons, sections, shifts, invite flow

- Crowd Types + Persons CRUD (73 tests)
- Festival Sections + Time Slots + Shifts CRUD met assign/claim flow (84 tests)
- Invite Flow + Member Management met InvitationService (109 tests)
- Schema v1.6 migraties volledig uitgevoerd
- DevSeeder bijgewerkt met crowd types voor testorganisatie
This commit is contained in:
2026-04-08 01:34:46 +02:00
parent c417a6647a
commit 9acb27af3a
114 changed files with 6916 additions and 984 deletions

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\AssignShiftRequest;
use App\Http\Requests\Api\V1\StoreShiftRequest;
use App\Http\Requests\Api\V1\UpdateShiftRequest;
use App\Http\Resources\Api\V1\ShiftAssignmentResource;
use App\Http\Resources\Api\V1\ShiftResource;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class ShiftController extends Controller
{
public function index(Event $event, FestivalSection $section): AnonymousResourceCollection
{
Gate::authorize('viewAny', [Shift::class, $event]);
$shifts = $section->shifts()
->with(['timeSlot', 'location'])
->get();
return ShiftResource::collection($shifts);
}
public function store(StoreShiftRequest $request, Event $event, FestivalSection $section): JsonResponse
{
Gate::authorize('create', [Shift::class, $event]);
$shift = $section->shifts()->create($request->validated());
$shift->load(['timeSlot', 'location']);
return $this->created(new ShiftResource($shift));
}
public function update(UpdateShiftRequest $request, Event $event, FestivalSection $section, Shift $shift): JsonResponse
{
Gate::authorize('update', [$shift, $event, $section]);
$shift->update($request->validated());
$shift->load(['timeSlot', 'location']);
return $this->success(new ShiftResource($shift->fresh()->load(['timeSlot', 'location'])));
}
public function destroy(Event $event, FestivalSection $section, Shift $shift): JsonResponse
{
Gate::authorize('delete', [$shift, $event, $section]);
$shift->delete();
return response()->json(null, 204);
}
public function assign(AssignShiftRequest $request, Event $event, FestivalSection $section, Shift $shift): JsonResponse
{
Gate::authorize('assign', [$shift, $event, $section]);
$personId = $request->validated('person_id');
// Check if shift is full
$approvedCount = $shift->shiftAssignments()->where('status', 'approved')->count();
if ($approvedCount >= $shift->slots_total) {
return $this->error('Shift is vol — alle slots zijn bezet.', 422);
}
// Check overlap conflict if allow_overlap is false
if (! $shift->allow_overlap) {
$conflict = ShiftAssignment::where('person_id', $personId)
->where('time_slot_id', $shift->time_slot_id)
->whereNotIn('status', ['rejected', 'cancelled'])
->exists();
if ($conflict) {
return $this->error('Deze persoon is al ingepland voor dit tijdslot.', 422);
}
}
$autoApprove = $section->crew_auto_accepts;
$assignment = $shift->shiftAssignments()->create([
'person_id' => $personId,
'time_slot_id' => $shift->time_slot_id,
'status' => $autoApprove ? 'approved' : 'approved',
'auto_approved' => $autoApprove,
'assigned_by' => $request->user()->id,
'assigned_at' => now(),
'approved_at' => now(),
]);
// Update shift status if full
$newApprovedCount = $shift->shiftAssignments()->where('status', 'approved')->count();
if ($newApprovedCount >= $shift->slots_total) {
$shift->update(['status' => 'full']);
}
return $this->created(new ShiftAssignmentResource($assignment));
}
public function claim(AssignShiftRequest $request, Event $event, FestivalSection $section, Shift $shift): JsonResponse
{
Gate::authorize('claim', [$shift, $event, $section]);
$personId = $request->validated('person_id');
// Check claiming slots available
$claimedCount = $shift->shiftAssignments()
->whereNotIn('status', ['rejected', 'cancelled'])
->count();
if ($shift->slots_open_for_claiming <= 0 || $claimedCount >= $shift->slots_open_for_claiming) {
return $this->error('Geen claimbare slots beschikbaar voor deze shift.', 422);
}
// Check overlap conflict if allow_overlap is false
if (! $shift->allow_overlap) {
$conflict = ShiftAssignment::where('person_id', $personId)
->where('time_slot_id', $shift->time_slot_id)
->whereNotIn('status', ['rejected', 'cancelled'])
->exists();
if ($conflict) {
return $this->error('Deze persoon is al ingepland voor dit tijdslot.', 422);
}
}
$autoApprove = $section->crew_auto_accepts;
$assignment = $shift->shiftAssignments()->create([
'person_id' => $personId,
'time_slot_id' => $shift->time_slot_id,
'status' => $autoApprove ? 'approved' : 'pending_approval',
'auto_approved' => $autoApprove,
'assigned_at' => now(),
'approved_at' => $autoApprove ? now() : null,
]);
return $this->created(new ShiftAssignmentResource($assignment));
}
}