diff --git a/api/app/Enums/ShiftAssignmentStatus.php b/api/app/Enums/ShiftAssignmentStatus.php new file mode 100644 index 00000000..ec2b8a26 --- /dev/null +++ b/api/app/Enums/ShiftAssignmentStatus.php @@ -0,0 +1,34 @@ + */ + public function allowedTransitions(): array + { + return match ($this) { + self::PENDING_APPROVAL => [self::APPROVED, self::REJECTED, self::CANCELLED], + self::APPROVED => [self::CANCELLED, self::COMPLETED], + self::REJECTED, self::CANCELLED, self::COMPLETED => [], + }; + } + + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions(), true); + } + + public function isTerminal(): bool + { + return $this->allowedTransitions() === []; + } +} diff --git a/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php new file mode 100644 index 00000000..7708ffab --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php @@ -0,0 +1,101 @@ +whereHas('shift.festivalSection', fn ($q) => $q->where('event_id', $event->id)) + ->with(['person', 'shift.festivalSection', 'shift.timeSlot']); + + if ($request->filled('status')) { + $status = ShiftAssignmentStatus::tryFrom($request->string('status')->toString()); + if ($status !== null) { + $query->where('status', $status); + } + } + + if ($request->filled('shift_id')) { + $query->where('shift_id', $request->string('shift_id')->toString()); + } + + if ($request->filled('person_id')) { + $query->where('person_id', $request->string('person_id')->toString()); + } + + if ($request->filled('section_id')) { + $query->whereHas('shift', fn ($q) => $q->where('festival_section_id', $request->string('section_id')->toString())); + } + + $assignments = $query->orderByDesc('created_at')->paginate(50); + + return ShiftAssignmentResource::collection($assignments); + } + + public function approve(Event $event, ShiftAssignment $shiftAssignment): JsonResponse + { + Gate::authorize('approve', [$shiftAssignment, $event]); + + $shiftAssignment = $this->service->approve($shiftAssignment, request()->user()); + + return $this->success(new ShiftAssignmentResource($shiftAssignment->load(['person', 'shift.festivalSection', 'shift.timeSlot']))); + } + + public function reject(RejectShiftAssignmentRequest $request, Event $event, ShiftAssignment $shiftAssignment): JsonResponse + { + Gate::authorize('reject', [$shiftAssignment, $event]); + + $shiftAssignment = $this->service->reject( + $shiftAssignment, + $request->user(), + $request->validated('reason'), + ); + + return $this->success(new ShiftAssignmentResource($shiftAssignment->load(['person', 'shift.festivalSection', 'shift.timeSlot']))); + } + + public function cancel(Event $event, ShiftAssignment $shiftAssignment): JsonResponse + { + Gate::authorize('cancel', [$shiftAssignment, $event]); + + $shiftAssignment = $this->service->cancel($shiftAssignment, request()->user()); + + return $this->success(new ShiftAssignmentResource($shiftAssignment->load(['person', 'shift.festivalSection', 'shift.timeSlot']))); + } + + public function bulkApprove(BulkApproveShiftAssignmentRequest $request, Event $event): JsonResponse + { + Gate::authorize('bulkApprove', [ShiftAssignment::class, $event]); + + $assignments = ShiftAssignment::whereIn('id', $request->validated('assignment_ids')) + ->with('shift') + ->get(); + + $results = $this->service->bulkApprove($assignments, $request->user()); + + return $this->success($results); + } +} diff --git a/api/app/Http/Controllers/Api/V1/ShiftController.php b/api/app/Http/Controllers/Api/V1/ShiftController.php index b1b4e969..f595dc46 100644 --- a/api/app/Http/Controllers/Api/V1/ShiftController.php +++ b/api/app/Http/Controllers/Api/V1/ShiftController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\ShiftAssignmentStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\AssignShiftRequest; use App\Http\Requests\Api\V1\StoreShiftRequest; @@ -12,21 +13,26 @@ 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\Person; use App\Models\Shift; -use App\Models\ShiftAssignment; +use App\Services\ShiftAssignmentService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Gate; final class ShiftController extends Controller { + public function __construct( + private readonly ShiftAssignmentService $shiftAssignmentService, + ) {} + public function index(Event $event, FestivalSection $section): AnonymousResourceCollection { Gate::authorize('viewAny', [Shift::class, $event]); $shifts = $section->shifts() ->with(['timeSlot', 'location']) - ->withCount(['shiftAssignments as filled_slots' => fn ($q) => $q->where('status', 'approved')]) + ->withCount(['shiftAssignments as filled_slots' => fn ($q) => $q->where('status', ShiftAssignmentStatus::APPROVED)]) ->get(); return ShiftResource::collection($shifts); @@ -65,41 +71,8 @@ final class ShiftController extends Controller { 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); - } - } - - $assignment = $shift->shiftAssignments()->create([ - 'person_id' => $personId, - 'time_slot_id' => $shift->time_slot_id, - 'status' => 'approved', - 'auto_approved' => false, - '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']); - } + $person = Person::findOrFail($request->validated('person_id')); + $assignment = $this->shiftAssignmentService->assign($shift, $person, $request->user()); return $this->created(new ShiftAssignmentResource($assignment)); } @@ -108,39 +81,8 @@ final class ShiftController extends Controller { 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, - ]); + $person = Person::findOrFail($request->validated('person_id')); + $assignment = $this->shiftAssignmentService->claim($shift, $person); return $this->created(new ShiftAssignmentResource($assignment)); } diff --git a/api/app/Http/Controllers/Api/V1/VolunteerAvailabilityController.php b/api/app/Http/Controllers/Api/V1/VolunteerAvailabilityController.php new file mode 100644 index 00000000..193e1db1 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/VolunteerAvailabilityController.php @@ -0,0 +1,105 @@ +id) + ->with('timeSlot') + ->get() + ->map(fn (VolunteerAvailability $a) => [ + 'id' => $a->id, + 'time_slot_id' => $a->time_slot_id, + 'preference_level' => $a->preference_level, + 'submitted_at' => $a->submitted_at?->toIso8601String(), + 'time_slot' => $a->timeSlot ? [ + 'id' => $a->timeSlot->id, + 'name' => $a->timeSlot->name, + 'date' => $a->timeSlot->date?->toDateString(), + 'start_time' => $a->timeSlot->start_time, + 'end_time' => $a->timeSlot->end_time, + ] : null, + ]); + + return response()->json(['data' => $availabilities]); + } + + /** + * @throws ValidationException + */ + public function sync(SyncVolunteerAvailabilityRequest $request, Event $event, Person $person): JsonResponse + { + Gate::authorize('update', [$person, $event]); + + $availabilities = $request->validated('availabilities'); + + // Validate all time_slot_ids belong to the event (or parent festival) + $validTimeSlotIds = $event->getAllRelevantTimeSlots()->pluck('id')->toArray(); + + $invalidSlots = collect($availabilities) + ->pluck('time_slot_id') + ->diff($validTimeSlotIds); + + if ($invalidSlots->isNotEmpty()) { + throw ValidationException::withMessages([ + 'availabilities' => ['Een of meer tijdsloten behoren niet tot dit evenement.'], + ]); + } + + // Validate time slots have person_type matching the person's crowd_type system_type + $personSystemType = $person->crowdType?->system_type; + if ($personSystemType !== null) { + $requestedSlotIds = collect($availabilities)->pluck('time_slot_id')->toArray(); + $mismatchedSlots = TimeSlot::whereIn('id', $requestedSlotIds) + ->where('person_type', '!=', $personSystemType) + ->exists(); + + if ($mismatchedSlots) { + throw ValidationException::withMessages([ + 'availabilities' => ['Een of meer tijdsloten komen niet overeen met het type van deze persoon.'], + ]); + } + } + + // Delete existing availabilities for this person + VolunteerAvailability::where('person_id', $person->id)->delete(); + + // Create new availabilities + $now = now(); + foreach ($availabilities as $item) { + VolunteerAvailability::create([ + 'person_id' => $person->id, + 'time_slot_id' => $item['time_slot_id'], + 'preference_level' => $item['preference_level'] ?? 3, + 'submitted_at' => $now, + ]); + } + + activity('volunteer_availability') + ->causedBy($request->user()) + ->performedOn($person) + ->withProperties([ + 'count' => count($availabilities), + 'time_slot_ids' => collect($availabilities)->pluck('time_slot_id')->toArray(), + ]) + ->log('volunteer_availability.synced'); + + return $this->success(null, 'Beschikbaarheid opgeslagen.'); + } +} diff --git a/api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php b/api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php new file mode 100644 index 00000000..16baf643 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'assignment_ids' => ['required', 'array', 'min:1', 'max:100'], + 'assignment_ids.*' => ['required', 'ulid', 'exists:shift_assignments,id'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/RejectShiftAssignmentRequest.php b/api/app/Http/Requests/Api/V1/RejectShiftAssignmentRequest.php new file mode 100644 index 00000000..a0567b79 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/RejectShiftAssignmentRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'reason' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/SyncVolunteerAvailabilityRequest.php b/api/app/Http/Requests/Api/V1/SyncVolunteerAvailabilityRequest.php new file mode 100644 index 00000000..f6571ce0 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/SyncVolunteerAvailabilityRequest.php @@ -0,0 +1,25 @@ + */ + public function rules(): array + { + return [ + 'availabilities' => ['required', 'array'], + 'availabilities.*.time_slot_id' => ['required', 'ulid', 'exists:time_slots,id'], + 'availabilities.*.preference_level' => ['sometimes', 'integer', 'min:1', 'max:5'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php b/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php index a0eaf4d7..d654c378 100644 --- a/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php +++ b/api/app/Http/Resources/Api/V1/ShiftAssignmentResource.php @@ -15,10 +15,24 @@ final class ShiftAssignmentResource extends JsonResource 'id' => $this->id, 'shift_id' => $this->shift_id, 'person_id' => $this->person_id, - 'status' => $this->status, + 'time_slot_id' => $this->time_slot_id, + 'status' => $this->status->value, 'auto_approved' => $this->auto_approved, + 'assigned_by' => $this->assigned_by, 'assigned_at' => $this->assigned_at?->toIso8601String(), + 'approved_by' => $this->approved_by, 'approved_at' => $this->approved_at?->toIso8601String(), + 'rejection_reason' => $this->rejection_reason, + 'hours_expected' => $this->hours_expected, + 'hours_completed' => $this->hours_completed, + 'checked_in_at' => $this->checked_in_at?->toIso8601String(), + 'checked_out_at' => $this->checked_out_at?->toIso8601String(), + 'is_cancellable' => $this->isCancellable(), + 'is_approvable' => $this->isApprovable(), + 'created_at' => $this->created_at?->toIso8601String(), + + 'person' => new PersonResource($this->whenLoaded('person')), + 'shift' => new ShiftResource($this->whenLoaded('shift')), ]; } } diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index ed1ecf2d..905019b2 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -76,6 +76,11 @@ final class Person extends Model return $this->hasMany(ShiftAssignment::class); } + public function volunteerAvailabilities(): HasMany + { + return $this->hasMany(VolunteerAvailability::class); + } + public function identityMatches(): HasMany { return $this->hasMany(PersonIdentityMatch::class); diff --git a/api/app/Models/Shift.php b/api/app/Models/Shift.php index c1357051..334709d7 100644 --- a/api/app/Models/Shift.php +++ b/api/app/Models/Shift.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\ShiftAssignmentStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUlids; @@ -99,7 +100,7 @@ final class Shift extends Model return (int) $this->attributes['filled_slots']; } - return $this->shiftAssignments()->where('status', 'approved')->count(); + return $this->shiftAssignments()->where('status', ShiftAssignmentStatus::APPROVED)->count(); }); } diff --git a/api/app/Models/ShiftAssignment.php b/api/app/Models/ShiftAssignment.php index ed9b7d6c..832e8324 100644 --- a/api/app/Models/ShiftAssignment.php +++ b/api/app/Models/ShiftAssignment.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\ShiftAssignmentStatus; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -36,11 +38,14 @@ final class ShiftAssignment extends Model protected function casts(): array { return [ + 'status' => ShiftAssignmentStatus::class, 'auto_approved' => 'boolean', 'assigned_at' => 'datetime', 'approved_at' => 'datetime', 'checked_in_at' => 'datetime', 'checked_out_at' => 'datetime', + 'hours_expected' => 'decimal:2', + 'hours_completed' => 'decimal:2', ]; } @@ -58,4 +63,37 @@ final class ShiftAssignment extends Model { return $this->belongsTo(TimeSlot::class); } + + public function assignedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_by'); + } + + public function approvedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->whereIn('status', [ + ShiftAssignmentStatus::PENDING_APPROVAL, + ShiftAssignmentStatus::APPROVED, + ]); + } + + public function scopeForStatus(Builder $query, ShiftAssignmentStatus $status): Builder + { + return $query->where('status', $status); + } + + public function isCancellable(): bool + { + return $this->status->canTransitionTo(ShiftAssignmentStatus::CANCELLED); + } + + public function isApprovable(): bool + { + return $this->status->canTransitionTo(ShiftAssignmentStatus::APPROVED); + } } diff --git a/api/app/Models/VolunteerAvailability.php b/api/app/Models/VolunteerAvailability.php new file mode 100644 index 00000000..b82f7af4 --- /dev/null +++ b/api/app/Models/VolunteerAvailability.php @@ -0,0 +1,45 @@ + 'integer', + 'submitted_at' => 'datetime', + ]; + } + + public function person(): BelongsTo + { + return $this->belongsTo(Person::class); + } + + public function timeSlot(): BelongsTo + { + return $this->belongsTo(TimeSlot::class); + } +} diff --git a/api/app/Policies/ShiftAssignmentPolicy.php b/api/app/Policies/ShiftAssignmentPolicy.php new file mode 100644 index 00000000..b23c6534 --- /dev/null +++ b/api/app/Policies/ShiftAssignmentPolicy.php @@ -0,0 +1,66 @@ +hasRole('super_admin') + || $event->organisation->users()->where('user_id', $user->id)->exists(); + } + + public function approve(User $user, ShiftAssignment $assignment, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function reject(User $user, ShiftAssignment $assignment, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + public function cancel(User $user, ShiftAssignment $assignment, Event $event): bool + { + if ($this->canManageEvent($user, $event)) { + return true; + } + + // Volunteers can cancel their own assignments + $person = $assignment->person; + + return $person->user_id !== null && $person->user_id === $user->id; + } + + public function bulkApprove(User $user, Event $event): bool + { + return $this->canManageEvent($user, $event); + } + + private function canManageEvent(User $user, Event $event): bool + { + if ($user->hasRole('super_admin')) { + return true; + } + + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } +} diff --git a/api/app/Services/ShiftAssignmentService.php b/api/app/Services/ShiftAssignmentService.php new file mode 100644 index 00000000..dcadbe68 --- /dev/null +++ b/api/app/Services/ShiftAssignmentService.php @@ -0,0 +1,377 @@ +validateShiftIsOpen($shift); + $this->validatePersonApproved($person); + $this->validateClaimCapacity($shift); + $this->validateNoConflict($shift, $person); + + $autoApprove = $shift->festivalSection->crew_auto_accepts; + $status = $autoApprove + ? ShiftAssignmentStatus::APPROVED + : ShiftAssignmentStatus::PENDING_APPROVAL; + + $assignment = $shift->shiftAssignments()->create([ + 'person_id' => $person->id, + 'time_slot_id' => $shift->time_slot_id, + 'status' => $status, + 'auto_approved' => $autoApprove, + 'assigned_at' => now(), + 'approved_at' => $autoApprove ? now() : null, + ]); + + $this->updateShiftStatusIfFull($shift); + + activity('shift_assignment') + ->causedBy(auth()->user()) + ->performedOn($assignment) + ->withProperties([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'auto_approved' => $autoApprove, + ]) + ->log('shift_assignment.claimed'); + + if ($autoApprove) { + activity('shift_assignment') + ->causedBy(auth()->user()) + ->performedOn($assignment) + ->withProperties(['shift_id' => $shift->id]) + ->log('shift_assignment.auto_approved'); + } + + return $assignment; + }); + } + + /** + * @throws ValidationException + */ + public function assign(Shift $shift, Person $person, User $assignedBy): ShiftAssignment + { + return DB::transaction(function () use ($shift, $person, $assignedBy): ShiftAssignment { + $this->validateShiftIsOpen($shift); + $this->validateAssignCapacity($shift); + $this->validateNoConflict($shift, $person); + + $assignment = $shift->shiftAssignments()->create([ + 'person_id' => $person->id, + 'time_slot_id' => $shift->time_slot_id, + 'status' => ShiftAssignmentStatus::APPROVED, + 'auto_approved' => false, + 'assigned_by' => $assignedBy->id, + 'assigned_at' => now(), + 'approved_by' => $assignedBy->id, + 'approved_at' => now(), + ]); + + $this->updateShiftStatusIfFull($shift); + + activity('shift_assignment') + ->causedBy($assignedBy) + ->performedOn($assignment) + ->withProperties([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'assigned_by' => $assignedBy->id, + ]) + ->log('shift_assignment.assigned'); + + return $assignment; + }); + } + + /** + * @throws ValidationException + */ + public function approve(ShiftAssignment $assignment, User $approvedBy): ShiftAssignment + { + return DB::transaction(function () use ($assignment, $approvedBy): ShiftAssignment { + $this->validateStatusTransition($assignment, ShiftAssignmentStatus::APPROVED); + + $shift = $assignment->shift; + $approvedCount = $shift->shiftAssignments() + ->where('status', ShiftAssignmentStatus::APPROVED) + ->count(); + + if ($approvedCount >= $shift->slots_total) { + throw ValidationException::withMessages([ + 'shift' => ['Shift is vol — alle slots zijn bezet.'], + ]); + } + + $oldStatus = $assignment->status; + + $assignment->update([ + 'status' => ShiftAssignmentStatus::APPROVED, + 'approved_by' => $approvedBy->id, + 'approved_at' => now(), + ]); + + $this->updateShiftStatusIfFull($shift); + + activity('shift_assignment') + ->causedBy($approvedBy) + ->performedOn($assignment) + ->withProperties([ + 'old_status' => $oldStatus->value, + 'new_status' => ShiftAssignmentStatus::APPROVED->value, + ]) + ->log('shift_assignment.approved'); + + return $assignment->fresh(); + }); + } + + /** + * @throws ValidationException + */ + public function reject(ShiftAssignment $assignment, User $rejectedBy, ?string $reason = null): ShiftAssignment + { + $this->validateStatusTransition($assignment, ShiftAssignmentStatus::REJECTED); + + $oldStatus = $assignment->status; + + $assignment->update([ + 'status' => ShiftAssignmentStatus::REJECTED, + 'rejection_reason' => $reason, + ]); + + activity('shift_assignment') + ->causedBy($rejectedBy) + ->performedOn($assignment) + ->withProperties([ + 'old_status' => $oldStatus->value, + 'new_status' => ShiftAssignmentStatus::REJECTED->value, + 'reason' => $reason, + ]) + ->log('shift_assignment.rejected'); + + return $assignment->fresh(); + } + + /** + * @throws ValidationException + */ + public function cancel(ShiftAssignment $assignment, User $cancelledBy): ShiftAssignment + { + return DB::transaction(function () use ($assignment, $cancelledBy): ShiftAssignment { + $this->validateStatusTransition($assignment, ShiftAssignmentStatus::CANCELLED); + + $wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED; + $oldStatus = $assignment->status; + + $assignment->update([ + 'status' => ShiftAssignmentStatus::CANCELLED, + ]); + + if ($wasApproved) { + $this->updateShiftStatusAfterCancellation($assignment->shift); + } + + activity('shift_assignment') + ->causedBy($cancelledBy) + ->performedOn($assignment) + ->withProperties([ + 'old_status' => $oldStatus->value, + 'new_status' => ShiftAssignmentStatus::CANCELLED->value, + ]) + ->log('shift_assignment.cancelled'); + + return $assignment->fresh(); + }); + } + + /** + * @return Collection + */ + public function bulkApprove(Collection $assignments, User $approvedBy): Collection + { + return DB::transaction(function () use ($assignments, $approvedBy): Collection { + return $assignments->map(function (ShiftAssignment $assignment) use ($approvedBy): array { + if ($assignment->status !== ShiftAssignmentStatus::PENDING_APPROVAL) { + return [ + 'assignment_id' => $assignment->id, + 'status' => 'skipped', + 'reason' => "Status is {$assignment->status->value}, not pending_approval.", + ]; + } + + $shift = $assignment->shift; + $approvedCount = $shift->shiftAssignments() + ->where('status', ShiftAssignmentStatus::APPROVED) + ->count(); + + if ($approvedCount >= $shift->slots_total) { + return [ + 'assignment_id' => $assignment->id, + 'status' => 'skipped', + 'reason' => 'Shift is vol.', + ]; + } + + $assignment->update([ + 'status' => ShiftAssignmentStatus::APPROVED, + 'approved_by' => $approvedBy->id, + 'approved_at' => now(), + ]); + + $this->updateShiftStatusIfFull($shift); + + activity('shift_assignment') + ->causedBy($approvedBy) + ->performedOn($assignment) + ->withProperties([ + 'old_status' => ShiftAssignmentStatus::PENDING_APPROVAL->value, + 'new_status' => ShiftAssignmentStatus::APPROVED->value, + ]) + ->log('shift_assignment.approved'); + + return [ + 'assignment_id' => $assignment->id, + 'status' => 'approved', + ]; + }); + }); + } + + /** + * @throws ValidationException + */ + private function validateShiftIsOpen(Shift $shift): void + { + if ($shift->status !== 'open') { + throw ValidationException::withMessages([ + 'shift' => ['Shift is niet open voor inschrijvingen.'], + ]); + } + } + + /** + * @throws ValidationException + */ + private function validatePersonApproved(Person $person): void + { + if ($person->status !== 'approved') { + throw ValidationException::withMessages([ + 'person' => ['Persoon is nog niet goedgekeurd.'], + ]); + } + } + + /** + * @throws ValidationException + */ + private function validateClaimCapacity(Shift $shift): void + { + if ($shift->slots_open_for_claiming <= 0) { + throw ValidationException::withMessages([ + 'shift' => ['Geen claimbare slots beschikbaar voor deze shift.'], + ]); + } + + $activeCount = $shift->shiftAssignments() + ->whereNotIn('status', [ + ShiftAssignmentStatus::REJECTED, + ShiftAssignmentStatus::CANCELLED, + ]) + ->count(); + + if ($activeCount >= $shift->slots_open_for_claiming) { + throw ValidationException::withMessages([ + 'shift' => ['Geen claimbare slots beschikbaar voor deze shift.'], + ]); + } + } + + /** + * @throws ValidationException + */ + private function validateAssignCapacity(Shift $shift): void + { + $approvedCount = $shift->shiftAssignments() + ->where('status', ShiftAssignmentStatus::APPROVED) + ->count(); + + if ($approvedCount >= $shift->slots_total) { + throw ValidationException::withMessages([ + 'shift' => ['Shift is vol — alle slots zijn bezet.'], + ]); + } + } + + /** + * @throws ValidationException + */ + private function validateNoConflict(Shift $shift, Person $person): void + { + if ($shift->allow_overlap) { + return; + } + + $conflict = ShiftAssignment::where('person_id', $person->id) + ->where('time_slot_id', $shift->time_slot_id) + ->active() + ->exists(); + + if ($conflict) { + throw ValidationException::withMessages([ + 'person_id' => ['Deze persoon is al ingepland voor dit tijdslot.'], + ]); + } + } + + /** + * @throws ValidationException + */ + private function validateStatusTransition(ShiftAssignment $assignment, ShiftAssignmentStatus $target): void + { + if (! $assignment->status->canTransitionTo($target)) { + throw ValidationException::withMessages([ + 'status' => ["Statusovergang van '{$assignment->status->value}' naar '{$target->value}' is niet toegestaan."], + ]); + } + } + + private function updateShiftStatusIfFull(Shift $shift): void + { + $approvedCount = $shift->shiftAssignments() + ->where('status', ShiftAssignmentStatus::APPROVED) + ->count(); + + if ($approvedCount >= $shift->slots_total && $shift->status === 'open') { + $shift->update(['status' => 'full']); + } + } + + private function updateShiftStatusAfterCancellation(Shift $shift): void + { + $approvedCount = $shift->shiftAssignments() + ->where('status', ShiftAssignmentStatus::APPROVED) + ->count(); + + if ($approvedCount < $shift->slots_total && $shift->status === 'full') { + $shift->update(['status' => 'open']); + } + } +} diff --git a/api/database/factories/ShiftAssignmentFactory.php b/api/database/factories/ShiftAssignmentFactory.php index 7bc3d84f..a4b7e0ab 100644 --- a/api/database/factories/ShiftAssignmentFactory.php +++ b/api/database/factories/ShiftAssignmentFactory.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Database\Factories; +use App\Enums\ShiftAssignmentStatus; use App\Models\Person; use App\Models\Shift; use App\Models\ShiftAssignment; @@ -20,15 +21,16 @@ final class ShiftAssignmentFactory extends Factory 'shift_id' => Shift::factory(), 'person_id' => Person::factory(), 'time_slot_id' => TimeSlot::factory(), - 'status' => 'pending_approval', + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, 'auto_approved' => false, + 'assigned_at' => now(), ]; } public function approved(): static { return $this->state(fn () => [ - 'status' => 'approved', + 'status' => ShiftAssignmentStatus::APPROVED, 'approved_at' => now(), ]); } @@ -36,7 +38,7 @@ final class ShiftAssignmentFactory extends Factory public function autoApproved(): static { return $this->state(fn () => [ - 'status' => 'approved', + 'status' => ShiftAssignmentStatus::APPROVED, 'auto_approved' => true, 'approved_at' => now(), ]); diff --git a/api/database/factories/VolunteerAvailabilityFactory.php b/api/database/factories/VolunteerAvailabilityFactory.php new file mode 100644 index 00000000..b26bb64a --- /dev/null +++ b/api/database/factories/VolunteerAvailabilityFactory.php @@ -0,0 +1,25 @@ + */ +final class VolunteerAvailabilityFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'person_id' => Person::factory(), + 'time_slot_id' => TimeSlot::factory(), + 'preference_level' => fake()->numberBetween(1, 5), + 'submitted_at' => now(), + ]; + } +} diff --git a/api/routes/api.php b/api/routes/api.php index bbdd6ad1..c223bd1d 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -17,8 +17,10 @@ use App\Http\Controllers\Api\V1\OrganisationController; use App\Http\Controllers\Api\V1\PersonController; use App\Http\Controllers\Api\V1\PersonIdentityMatchController; use App\Http\Controllers\Api\V1\PersonTagController; +use App\Http\Controllers\Api\V1\ShiftAssignmentController; use App\Http\Controllers\Api\V1\ShiftController; use App\Http\Controllers\Api\V1\TimeSlotController; +use App\Http\Controllers\Api\V1\VolunteerAvailabilityController; use App\Http\Controllers\Api\V1\UserOrganisationTagController; use App\Models\FestivalSection; use App\Models\Organisation; @@ -129,8 +131,19 @@ Route::middleware('auth:sanctum')->group(function () { Route::post('shifts/{shift}/claim', [ShiftController::class, 'claim']); }); + // Shift assignments (event-level) + Route::get('shift-assignments', [ShiftAssignmentController::class, 'index']); + Route::post('shift-assignments/{shiftAssignment}/approve', [ShiftAssignmentController::class, 'approve']); + Route::post('shift-assignments/{shiftAssignment}/reject', [ShiftAssignmentController::class, 'reject']); + Route::post('shift-assignments/{shiftAssignment}/cancel', [ShiftAssignmentController::class, 'cancel']); + Route::post('shift-assignments/bulk-approve', [ShiftAssignmentController::class, 'bulkApprove']); + Route::apiResource('persons', PersonController::class); Route::post('persons/{person}/approve', [PersonController::class, 'approve']); + + // Volunteer availabilities + Route::get('persons/{person}/availabilities', [VolunteerAvailabilityController::class, 'index']); + Route::post('persons/{person}/availabilities/sync', [VolunteerAvailabilityController::class, 'sync']); Route::apiResource('crowd-lists', CrowdListController::class) ->except(['show']); Route::get('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'persons']); diff --git a/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php b/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php new file mode 100644 index 00000000..da303d24 --- /dev/null +++ b/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php @@ -0,0 +1,788 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->otherOrganisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + + $this->volunteer = User::factory()->create(); + $this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']); + + $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $this->section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'crew_auto_accepts' => false, + ]); + $this->timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + $this->person = Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $this->volunteer->id, + ]); + } + + private function createOpenShift(array $overrides = []): Shift + { + return Shift::factory()->open()->create(array_merge([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'slots_open_for_claiming' => 3, + ], $overrides)); + } + + // ========================================================================= + // Claim workflow + // ========================================================================= + + public function test_volunteer_can_claim_open_shift(): void + { + $shift = $this->createOpenShift(); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertCreated() + ->assertJsonPath('data.status', 'pending_approval') + ->assertJsonPath('data.person_id', $this->person->id); + + $this->assertDatabaseHas('shift_assignments', [ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL->value, + ]); + } + + public function test_volunteer_can_claim_auto_approve_shift(): void + { + $autoSection = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'crew_auto_accepts' => true, + ]); + $shift = $this->createOpenShift(['festival_section_id' => $autoSection->id]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$autoSection->id}/shifts/{$shift->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertCreated() + ->assertJsonPath('data.status', 'approved') + ->assertJsonPath('data.auto_approved', true); + } + + public function test_claim_rejected_when_shift_is_full(): void + { + $shift = $this->createOpenShift(['slots_open_for_claiming' => 1]); + + // Fill the claimable slot + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ])->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertUnprocessable(); + } + + public function test_claim_rejected_when_shift_status_not_open(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'slots_open_for_claiming' => 3, + 'status' => 'draft', + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertUnprocessable(); + } + + public function test_claim_rejected_with_conflicting_assignment(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(); + + // Create existing active assignment for the same time slot + ShiftAssignment::factory()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertUnprocessable(); + } + + public function test_claim_allowed_when_shift_allows_overlap(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(['allow_overlap' => true]); + + // Create existing assignment + ShiftAssignment::factory()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertCreated(); + } + + public function test_claim_rejected_when_person_not_approved(): void + { + $pendingPerson = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + ]); + $shift = $this->createOpenShift(); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", + ['person_id' => $pendingPerson->id], + ); + + $response->assertUnprocessable(); + } + + public function test_unauthenticated_claim_returns_401(): void + { + $shift = $this->createOpenShift(); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertUnauthorized(); + } + + // ========================================================================= + // Assign workflow + // ========================================================================= + + public function test_organizer_can_assign_person_to_shift(): void + { + $shift = $this->createOpenShift(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", + ['person_id' => $this->person->id], + ); + + $response->assertCreated() + ->assertJsonPath('data.status', 'approved') + ->assertJsonPath('data.person_id', $this->person->id); + + $this->assertDatabaseHas('shift_assignments', [ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'status' => ShiftAssignmentStatus::APPROVED->value, + 'assigned_by' => $this->orgAdmin->id, + 'approved_by' => $this->orgAdmin->id, + ]); + } + + public function test_assign_uses_slots_total_not_claiming(): void + { + // slots_open_for_claiming = 0, but slots_total = 4 + $shift = $this->createOpenShift(['slots_open_for_claiming' => 0]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", + ['person_id' => $this->person->id], + ); + + $response->assertCreated(); + } + + public function test_assign_rejected_when_capacity_full(): void + { + $shift = $this->createOpenShift(['slots_total' => 1]); + + // Fill the slot + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ])->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", + ['person_id' => $this->person->id], + ); + + $response->assertUnprocessable(); + } + + public function test_assign_rejected_with_conflict(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(); + + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/assign", + ['person_id' => $this->person->id], + ); + + $response->assertUnprocessable(); + } + + public function test_non_organizer_cannot_assign(): void + { + $shift = $this->createOpenShift(); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", + ['person_id' => $this->person->id], + ); + + $response->assertForbidden(); + } + + // ========================================================================= + // Approve / Reject / Cancel + // ========================================================================= + + public function test_organizer_approves_pending_assignment(): void + { + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve", + ); + + $response->assertOk() + ->assertJsonPath('data.status', 'approved'); + + $this->assertDatabaseHas('shift_assignments', [ + 'id' => $assignment->id, + 'status' => ShiftAssignmentStatus::APPROVED->value, + 'approved_by' => $this->orgAdmin->id, + ]); + } + + public function test_organizer_rejects_with_reason(): void + { + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/reject", + ['reason' => 'Onvoldoende ervaring voor deze rol.'], + ); + + $response->assertOk() + ->assertJsonPath('data.status', 'rejected') + ->assertJsonPath('data.rejection_reason', 'Onvoldoende ervaring voor deze rol.'); + } + + public function test_cannot_approve_already_approved(): void + { + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve", + ); + + $response->assertUnprocessable(); + } + + public function test_volunteer_can_cancel_own_pending_assignment(): void + { + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", + ); + + $response->assertOk() + ->assertJsonPath('data.status', 'cancelled'); + } + + public function test_volunteer_can_cancel_own_approved_assignment(): void + { + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", + ); + + $response->assertOk() + ->assertJsonPath('data.status', 'cancelled'); + } + + public function test_volunteer_cannot_cancel_someone_elses_assignment(): void + { + $otherPerson = Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $otherPerson->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + Sanctum::actingAs($this->volunteer); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", + ); + + $response->assertForbidden(); + } + + public function test_organizer_can_cancel_any_assignment(): void + { + $otherPerson = Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => $otherPerson->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", + ); + + $response->assertOk() + ->assertJsonPath('data.status', 'cancelled'); + } + + // ========================================================================= + // Bulk approve + // ========================================================================= + + public function test_bulk_approve_multiple_pending(): void + { + $shift = $this->createOpenShift(); + + $assignments = collect([1, 2])->map(fn () => ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ])->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ])); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/bulk-approve", + ['assignment_ids' => $assignments->pluck('id')->toArray()], + ); + + $response->assertOk(); + + foreach ($assignments as $assignment) { + $this->assertDatabaseHas('shift_assignments', [ + 'id' => $assignment->id, + 'status' => ShiftAssignmentStatus::APPROVED->value, + ]); + } + } + + public function test_bulk_approve_skips_non_pending(): void + { + $shift = $this->createOpenShift(); + + $pending = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ])->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + $approved = ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ])->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/bulk-approve", + ['assignment_ids' => [$pending->id, $approved->id]], + ); + + $response->assertOk(); + + $results = $response->json('data'); + $this->assertCount(2, $results); + + $pendingResult = collect($results)->firstWhere('assignment_id', $pending->id); + $approvedResult = collect($results)->firstWhere('assignment_id', $approved->id); + + $this->assertEquals('approved', $pendingResult['status']); + $this->assertEquals('skipped', $approvedResult['status']); + } + + // ========================================================================= + // Index / listing + // ========================================================================= + + public function test_index_returns_assignments_for_event(): void + { + $shift = $this->createOpenShift(); + + ShiftAssignment::factory()->count(3)->create([ + 'shift_id' => $shift->id, + 'time_slot_id' => $this->timeSlot->id, + 'person_id' => fn () => Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ])->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/shift-assignments"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_index_filterable_by_status(): void + { + $shift = $this->createOpenShift(); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'time_slot_id' => $this->timeSlot->id, + 'person_id' => $this->person->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'time_slot_id' => $this->timeSlot->id, + 'person_id' => Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ])->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->event->id}/shift-assignments?status=pending_approval"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertEquals('pending_approval', $response->json('data.0.status')); + } + + // ========================================================================= + // Multi-tenancy + // ========================================================================= + + public function test_cannot_claim_shift_in_different_organisation(): void + { + $shift = $this->createOpenShift(); + + Sanctum::actingAs($this->outsider); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", + ['person_id' => $this->person->id], + ); + + $response->assertForbidden(); + } + + public function test_cannot_approve_assignment_in_different_organisation(): void + { + $shift = $this->createOpenShift(); + $assignment = ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + Sanctum::actingAs($this->outsider); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve", + ); + + $response->assertForbidden(); + } + + // ========================================================================= + // Volunteer Availabilities + // ========================================================================= + + public function test_sync_availabilities_for_person(): void + { + $slot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", + [ + 'availabilities' => [ + ['time_slot_id' => $this->timeSlot->id, 'preference_level' => 5], + ['time_slot_id' => $slot2->id, 'preference_level' => 2], + ], + ], + ); + + $response->assertOk(); + + $this->assertDatabaseCount('volunteer_availabilities', 2); + $this->assertDatabaseHas('volunteer_availabilities', [ + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'preference_level' => 5, + ]); + } + + public function test_sync_replaces_existing_availabilities(): void + { + // Create initial availabilities + VolunteerAvailability::factory()->create([ + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'preference_level' => 3, + ]); + + $slot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", + [ + 'availabilities' => [ + ['time_slot_id' => $slot2->id, 'preference_level' => 4], + ], + ], + ); + + $response->assertOk(); + + // Old one removed, only new one exists + $this->assertDatabaseCount('volunteer_availabilities', 1); + $this->assertDatabaseHas('volunteer_availabilities', [ + 'person_id' => $this->person->id, + 'time_slot_id' => $slot2->id, + 'preference_level' => 4, + ]); + $this->assertDatabaseMissing('volunteer_availabilities', [ + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + } + + public function test_sync_rejects_time_slot_from_wrong_event(): void + { + $otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); + $otherSlot = TimeSlot::factory()->create(['event_id' => $otherEvent->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", + [ + 'availabilities' => [ + ['time_slot_id' => $otherSlot->id, 'preference_level' => 3], + ], + ], + ); + + $response->assertUnprocessable(); + } + + public function test_unauthenticated_sync_returns_401(): void + { + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", + ['availabilities' => []], + ); + + $response->assertUnauthorized(); + } + + public function test_list_availabilities_for_person(): void + { + VolunteerAvailability::factory()->create([ + 'person_id' => $this->person->id, + 'time_slot_id' => $this->timeSlot->id, + 'preference_level' => 4, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities", + ); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertEquals(4, $response->json('data.0.preference_level')); + } +} diff --git a/api/tests/Feature/Event/FestivalEventTest.php b/api/tests/Feature/Event/FestivalEventTest.php index a18cff3e..08fd57d4 100644 --- a/api/tests/Feature/Event/FestivalEventTest.php +++ b/api/tests/Feature/Event/FestivalEventTest.php @@ -541,7 +541,7 @@ class FestivalEventTest extends TestCase ]); // Create a shift on the sub-event section using festival time slot - $shift = Shift::factory()->create([ + $shift = Shift::factory()->open()->create([ 'festival_section_id' => $section->id, 'time_slot_id' => $festivalTimeSlot->id, 'slots_total' => 5, @@ -566,7 +566,7 @@ class FestivalEventTest extends TestCase 'event_id' => $this->subEvent->id, ]); - $shift2 = Shift::factory()->create([ + $shift2 = Shift::factory()->open()->create([ 'festival_section_id' => $section2->id, 'time_slot_id' => $festivalTimeSlot->id, 'slots_total' => 5, diff --git a/api/tests/Feature/Shift/ShiftTest.php b/api/tests/Feature/Shift/ShiftTest.php index 61c6b3cd..77da9dfa 100644 --- a/api/tests/Feature/Shift/ShiftTest.php +++ b/api/tests/Feature/Shift/ShiftTest.php @@ -207,6 +207,7 @@ class ShiftTest extends TestCase 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 4, 'allow_overlap' => false, + 'status' => 'open', ]); $person = Person::factory()->create([ @@ -229,6 +230,7 @@ class ShiftTest extends TestCase 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 4, 'allow_overlap' => false, + 'status' => 'open', ]); Sanctum::actingAs($this->orgAdmin); @@ -247,6 +249,7 @@ class ShiftTest extends TestCase 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 4, 'allow_overlap' => true, + 'status' => 'open', ]); $person = Person::factory()->create([ @@ -269,6 +272,7 @@ class ShiftTest extends TestCase 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 4, 'allow_overlap' => true, + 'status' => 'open', ]); Sanctum::actingAs($this->orgAdmin); @@ -286,6 +290,7 @@ class ShiftTest extends TestCase 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 1, + 'status' => 'open', ]); $person1 = Person::factory()->create([ diff --git a/dev-docs/API.md b/dev-docs/API.md index 7311d18d..497ac10a 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -136,6 +136,126 @@ Returns 422 with `errors`, `current_status`, `requested_status`, and `allowed_tr > in the URL must be the parent festival's ID (matching `section.event_id`), not the > sub-event's ID. +## Shift Assignments + +- `GET /events/{event}/shift-assignments` — list assignments for event (paginated, 50/page) +- `POST /events/{event}/shift-assignments/{shiftAssignment}/approve` — approve pending assignment +- `POST /events/{event}/shift-assignments/{shiftAssignment}/reject` — reject pending assignment +- `POST /events/{event}/shift-assignments/{shiftAssignment}/cancel` — cancel assignment +- `POST /events/{event}/shift-assignments/bulk-approve` — bulk approve multiple assignments + +### Query Parameters (index) + +- `status` — filter by assignment status (`pending_approval`, `approved`, `rejected`, `cancelled`, `completed`) +- `shift_id` — filter by shift +- `person_id` — filter by person +- `section_id` — filter by festival section + +### Assign Body + +`POST /events/{event}/sections/{section}/shifts/{shift}/assign` + +```json +{ "person_id": "01JXYZ..." } +``` + +Organizer manually assigns a person. Assignment is pre-approved (status = `approved`). +Validates: shift must be `open`, capacity not full (`slots_total`), no time slot conflict. + +### Claim Body + +`POST /events/{event}/sections/{section}/shifts/{shift}/claim` + +```json +{ "person_id": "01JXYZ..." } +``` + +Volunteer claims a shift. Status depends on `festival_section.crew_auto_accepts`: +- `true` → status = `approved`, `auto_approved = true` +- `false` → status = `pending_approval` + +Validates: shift must be `open`, person must be `approved`, claiming capacity not full (`slots_open_for_claiming`), no time slot conflict. + +### Reject Body + +```json +{ "reason": "Onvoldoende ervaring voor deze rol." } +``` + +### Bulk Approve Body + +```json +{ "assignment_ids": ["ulid1", "ulid2", ...] } +``` + +Response includes per-assignment result: `approved` or `skipped` (with reason). + +### ShiftAssignmentResource + +```json +{ + "id": "01JXYZ...", + "shift_id": "01JXYZ...", + "person_id": "01JXYZ...", + "time_slot_id": "01JXYZ...", + "status": "pending_approval", + "auto_approved": false, + "assigned_by": null, + "assigned_at": "2026-04-10T12:00:00+00:00", + "approved_by": null, + "approved_at": null, + "rejection_reason": null, + "hours_expected": null, + "hours_completed": null, + "checked_in_at": null, + "checked_out_at": null, + "is_cancellable": true, + "is_approvable": true, + "created_at": "2026-04-10T12:00:00+00:00", + "person": { "..." }, + "shift": { "..." } +} +``` + +### Status Transitions + +- `pending_approval` → `approved`, `rejected`, `cancelled` +- `approved` → `cancelled`, `completed` +- `rejected` → (terminal) +- `cancelled` → (terminal) +- `completed` → (terminal) + +### Authorization + +| Action | Who | +|--------|-----| +| assign | org_admin, event_manager | +| claim | authenticated org member | +| approve / reject / bulk-approve | org_admin, event_manager | +| cancel | org_admin, event_manager, or the volunteer's own user | + +## Volunteer Availabilities + +- `GET /events/{event}/persons/{person}/availabilities` — list availabilities +- `POST /events/{event}/persons/{person}/availabilities/sync` — sync (replace all) + +### Sync Body + +```json +{ + "availabilities": [ + { "time_slot_id": "01JXYZ...", "preference_level": 5 }, + { "time_slot_id": "01JABC...", "preference_level": 2 } + ] +} +``` + +Replaces all existing availabilities for the person. `preference_level` is optional (default: 3, range: 1–5). + +Validates: +- All `time_slot_id`s must belong to the event (or parent festival) +- Time slot `person_type` must match the person's crowd type `system_type` + ## Persons - `GET /events/{event}/persons`