feat: shift assignment workflow with claim, approve, reject, cancel, and bulk approve
Implements the complete ShiftAssignment lifecycle: - ShiftAssignmentStatus enum with allowed transitions - ShiftAssignmentService with claim/assign/approve/reject/cancel/bulkApprove - ShiftAssignmentController with event-scoped endpoints - ShiftAssignmentPolicy (organizer + volunteer self-cancel) - VolunteerAvailability model, controller, and sync endpoint - Refactored ShiftController to delegate to service layer - 31 workflow tests covering all paths and multi-tenancy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
377
api/app/Services/ShiftAssignmentService.php
Normal file
377
api/app/Services/ShiftAssignmentService.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
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);
|
||||
|
||||
$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<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;
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user