Files
crewli/api/app/Services/ShiftAssignmentService.php
bert.hausmans d2f282eb4c feat: split name into first_name + last_name across users, persons, and companies
Cross-cutting migration affecting the entire stack:
- Database: 3 migrations splitting name columns with data migration
- Models: first_name/last_name on User, Person; contact_first_name/contact_last_name on Company; backward-compatible name accessors
- API: all resources return first_name, last_name, full_name; assignablePersons endpoint updated
- Requests: validation rules updated for all person/user/company forms
- Services: VolunteerRegistrationService, ShiftAssignmentService, InvitationService updated
- Frontend: TypeScript types, Zod schemas, all forms split into Voornaam/Achternaam fields
- Display: all person/user name references use full_name; initials use first_name[0]+last_name[0]
- Tests: all 371 tests passing
- Docs: SCHEMA.md and API.md updated

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

450 lines
15 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,
'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->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->update([
'status' => ShiftAssignmentStatus::APPROVED,
'assigned_by' => $assignedBy->id,
'assigned_at' => now(),
'approved_by' => $assignedBy->id,
'approved_at' => now(),
'rejection_reason' => null,
'cancelled_by' => null,
'cancellation_source' => null,
'cancelled_at' => null,
]);
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,
'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,
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->update([
'status' => ShiftAssignmentStatus::CANCELLED,
'cancelled_by' => $cancelledBy->id,
'cancellation_source' => $source,
'cancelled_at' => now(),
]);
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;
$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, 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']);
}
}
}