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 */ 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']); } } }