Files
crewli/api/app/Services/ShiftAssignmentService.php
bert.hausmans ed1eddd486 fix: allow organiser to approve shift assignments when shift is full
The approve() and bulkApprove() methods in ShiftAssignmentService
hard-blocked with a 422 when all slots were filled. This was incorrect
for organiser actions — only volunteer claims (portal self-service)
should enforce capacity limits. Organiser assign() already allowed
overbooking, making the approve block inconsistent.

Changes:
- Remove capacity hard-block from approve() and bulkApprove(), replace
  with audit log entry (shift.overbooked_approval)
- Add overbook confirmation dialog in ShiftDetailPanel before approving
  a full shift (single + bulk approve)
- Add onError handlers to all mutations in ShiftDetailPanel (approve,
  reject, cancel, bulk-approve) so errors display in the snackbar
- Add global 422 validation error display in axios interceptor via
  useNotificationStore as safety net for all components
- Add PHPUnit test for approve-when-full scenario

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:42:04 +02:00

463 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\CancellationSource;
use App\Enums\ShiftAssignmentStatus;
use App\Models\Person;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class ShiftAssignmentService
{
/**
* @throws ValidationException
*/
public function claim(Shift $shift, Person $person): ShiftAssignment
{
return DB::transaction(function () use ($shift, $person): ShiftAssignment {
$this->validateShiftIsOpen($shift);
$this->validatePersonApproved($person);
$this->validateClaimCapacity($shift);
$this->validateNoConflict($shift, $person, isClaim: true);
$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,
]);
$assignment->auto_approved = $autoApprove;
$assignment->assigned_at = now();
$assignment->approved_at = $autoApprove ? now() : null;
$assignment->save();
$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->validateNoConflict($shift, $person);
// Check for existing cancelled/rejected assignment on THIS shift
$existing = ShiftAssignment::where('shift_id', $shift->id)
->where('person_id', $person->id)
->whereIn('status', [
ShiftAssignmentStatus::CANCELLED,
ShiftAssignmentStatus::REJECTED,
])
->first();
if ($existing) {
$previousStatus = $existing->status->value;
$existing->status = ShiftAssignmentStatus::APPROVED;
$existing->assigned_by = $assignedBy->id;
$existing->assigned_at = now();
$existing->approved_by = $assignedBy->id;
$existing->approved_at = now();
$existing->rejection_reason = null;
$existing->cancelled_by = null;
$existing->cancellation_source = null;
$existing->cancelled_at = null;
$existing->save();
activity('shift_assignment')
->causedBy($assignedBy)
->performedOn($existing)
->withProperties([
'previous_status' => $previousStatus,
'person_name' => $person->full_name,
])
->log('shift_assignment.reactivated');
$this->updateShiftStatusIfFull($shift);
return $existing->fresh();
}
// Log overbooking for audit trail (organizers may intentionally overbook)
$filledSlots = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($filledSlots >= $shift->slots_total) {
activity('shift_assignment')
->causedBy($assignedBy)
->performedOn($shift)
->withProperties([
'filled_slots' => $filledSlots,
'slots_total' => $shift->slots_total,
'person_id' => $person->id,
'person_name' => $person->full_name,
])
->log('shift.overbooked_assignment');
}
$assignment = $shift->shiftAssignments()->create([
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
]);
$assignment->auto_approved = false;
$assignment->assigned_by = $assignedBy->id;
$assignment->assigned_at = now();
$assignment->approved_by = $assignedBy->id;
$assignment->approved_at = now();
$assignment->save();
$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;
$oldStatus = $assignment->status;
// Log overbooking for audit trail (organisers may intentionally overbook)
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total) {
activity('shift_assignment')
->causedBy($approvedBy)
->performedOn($shift)
->withProperties([
'filled_slots' => $approvedCount,
'slots_total' => $shift->slots_total,
'assignment_id' => $assignment->id,
'person_id' => $assignment->person_id,
])
->log('shift.overbooked_approval');
}
$assignment->status = ShiftAssignmentStatus::APPROVED;
$assignment->approved_by = $approvedBy->id;
$assignment->approved_at = now();
$assignment->save();
$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->status = ShiftAssignmentStatus::REJECTED;
$assignment->rejection_reason = $reason;
$assignment->save();
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,
CancellationSource $source = CancellationSource::ORGANISER,
): ShiftAssignment {
return DB::transaction(function () use ($assignment, $cancelledBy, $source): ShiftAssignment {
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::CANCELLED);
$wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED;
$oldStatus = $assignment->status;
$assignment->status = ShiftAssignmentStatus::CANCELLED;
$assignment->cancelled_by = $cancelledBy->id;
$assignment->cancellation_source = $source;
$assignment->cancelled_at = now();
$assignment->save();
if ($wasApproved) {
$this->updateShiftStatusAfterCancellation($assignment->shift);
}
activity('shift_assignment')
->causedBy($cancelledBy)
->performedOn($assignment)
->withProperties([
'old_status' => $oldStatus->value,
'new_status' => ShiftAssignmentStatus::CANCELLED->value,
'source' => $source->value,
])
->log('shift_assignment.cancelled');
return $assignment->fresh();
});
}
/**
* @return Collection<int, array{assignment_id: string, status: string}>
*/
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;
// Log overbooking for audit trail
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total) {
activity('shift_assignment')
->causedBy($approvedBy)
->performedOn($shift)
->withProperties([
'filled_slots' => $approvedCount,
'slots_total' => $shift->slots_total,
'assignment_id' => $assignment->id,
])
->log('shift.overbooked_approval');
}
$assignment->status = ShiftAssignmentStatus::APPROVED;
$assignment->approved_by = $approvedBy->id;
$assignment->approved_at = now();
$assignment->save();
$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, bool $isClaim = false): void
{
if ($shift->allow_overlap) {
return;
}
$existingAssignment = ShiftAssignment::where('person_id', $person->id)
->where('time_slot_id', $shift->time_slot_id)
->active()
->with(['shift.festivalSection', 'shift.timeSlot'])
->first();
if ($existingAssignment) {
$section = $existingAssignment->shift->festivalSection->name;
$timeSlot = $existingAssignment->shift->timeSlot->name;
$time = $existingAssignment->shift->timeSlot->start_time
. '' . $existingAssignment->shift->timeSlot->end_time;
$message = $isClaim
? "Je bent al ingepland bij \"{$section}\" voor {$timeSlot} ({$time})."
: "Deze persoon is al ingepland bij \"{$section}\" voor {$timeSlot} ({$time}).";
throw ValidationException::withMessages([
'person_id' => [$message],
]);
}
}
/**
* @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']);
}
}
}