From 62504213552939b5bd9f3bcb0ed63f0c0fc0b510 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 13 Apr 2026 07:39:19 +0200 Subject: [PATCH] feat(api): add upcoming_shift to portal me endpoint Query next approved shift assignment with future time slot, ordered by date and start time, and return formatted shift data in the portal me response for the dashboard "Komende shift" card. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controllers/Api/V1/PortalMeController.php | 40 ++- .../Api/V1/PortalMeUpcomingShiftTest.php | 269 ++++++++++++++++++ 2 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php diff --git a/api/app/Http/Controllers/Api/V1/PortalMeController.php b/api/app/Http/Controllers/Api/V1/PortalMeController.php index 305e871a..7c9908e1 100644 --- a/api/app/Http/Controllers/Api/V1/PortalMeController.php +++ b/api/app/Http/Controllers/Api/V1/PortalMeController.php @@ -4,11 +4,13 @@ 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\PortalMeRequest; use App\Http\Resources\Api\V1\PersonResource; use App\Models\Event; use App\Models\Person; +use App\Models\TimeSlot; use Illuminate\Http\JsonResponse; final class PortalMeController extends Controller @@ -37,6 +39,42 @@ final class PortalMeController extends Controller return $this->notFound('No registration found for this event'); } - return $this->success(new PersonResource($person)); + $upcomingShift = $person->shiftAssignments() + ->where('status', ShiftAssignmentStatus::APPROVED) + ->whereHas('shift.timeSlot', fn ($q) => $q->where('date', '>=', now()->toDateString())) + ->with([ + 'shift:id,title,festival_section_id,time_slot_id,location_id', + 'shift.timeSlot:id,name,date,start_time,end_time', + 'shift.festivalSection:id,name', + 'shift.location:id,name', + ]) + ->orderBy( + TimeSlot::select('date') + ->whereColumn('time_slots.id', 'shift_assignments.time_slot_id') + ->limit(1) + ) + ->orderBy( + TimeSlot::select('start_time') + ->whereColumn('time_slots.id', 'shift_assignments.time_slot_id') + ->limit(1) + ) + ->first(); + + $formattedShift = null; + if ($upcomingShift) { + $timeSlot = $upcomingShift->shift->timeSlot; + $formattedShift = [ + 'date' => $timeSlot->date->toDateString(), + 'time' => substr($timeSlot->start_time, 0, 5) . ' - ' . substr($timeSlot->end_time, 0, 5), + 'title' => $upcomingShift->shift->title, + 'section' => $upcomingShift->shift->festivalSection?->name, + 'location' => $upcomingShift->shift->location?->name, + ]; + } + + $data = (new PersonResource($person))->resolve(); + $data['upcoming_shift'] = $formattedShift; + + return $this->success($data); } } diff --git a/api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php b/api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php new file mode 100644 index 00000000..2734499d --- /dev/null +++ b/api/tests/Feature/Api/V1/PortalMeUpcomingShiftTest.php @@ -0,0 +1,269 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->volunteerCrowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'published', + ]); + $this->user = User::factory()->create(); + $this->person = Person::factory()->approved()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->volunteerCrowdType->id, + 'user_id' => $this->user->id, + 'email' => $this->user->email, + ]); + } + + public function test_portal_me_returns_upcoming_shift_for_approved_person(): void + { + $futureDate = now()->addDays(7)->toDateString(); + + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + 'date' => $futureDate, + 'start_time' => '18:00:00', + 'end_time' => '02:00:00', + ]); + + $section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'name' => 'Bar Hoofdpodium', + ]); + + $location = Location::factory()->create([ + 'event_id' => $this->event->id, + 'name' => 'Festivalterrein', + ]); + + $shift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $timeSlot->id, + 'location_id' => $location->id, + 'title' => 'Tapper', + ]); + + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $timeSlot->id, + ]); + + Sanctum::actingAs($this->user); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.upcoming_shift.date', $futureDate); + $response->assertJsonPath('data.upcoming_shift.time', '18:00 - 02:00'); + $response->assertJsonPath('data.upcoming_shift.title', 'Tapper'); + $response->assertJsonPath('data.upcoming_shift.section', 'Bar Hoofdpodium'); + $response->assertJsonPath('data.upcoming_shift.location', 'Festivalterrein'); + } + + public function test_portal_me_returns_null_upcoming_shift_when_no_future_shifts(): void + { + // Create a past shift assignment + $pastDate = now()->subDays(7)->toDateString(); + + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + 'date' => $pastDate, + 'start_time' => '18:00:00', + 'end_time' => '02:00:00', + ]); + + $section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + ]); + + $shift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $timeSlot->id, + 'title' => 'Tapper', + ]); + + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $timeSlot->id, + ]); + + Sanctum::actingAs($this->user); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.upcoming_shift', null); + } + + public function test_portal_me_returns_null_upcoming_shift_for_pending_person(): void + { + $pendingPerson = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->volunteerCrowdType->id, + 'user_id' => null, + 'status' => 'pending', + ]); + + $pendingUser = User::factory()->create(['email' => $pendingPerson->email]); + $pendingPerson->update(['user_id' => $pendingUser->id]); + + $futureDate = now()->addDays(7)->toDateString(); + + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + 'date' => $futureDate, + ]); + + $section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + ]); + + $shift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $timeSlot->id, + ]); + + // Assignment is pending_approval, not approved + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $pendingPerson->id, + 'time_slot_id' => $timeSlot->id, + 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, + ]); + + Sanctum::actingAs($pendingUser); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.upcoming_shift', null); + } + + public function test_portal_me_returns_nearest_future_shift(): void + { + $nearDate = now()->addDays(3)->toDateString(); + $farDate = now()->addDays(10)->toDateString(); + + $nearTimeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + 'date' => $nearDate, + 'start_time' => '10:00:00', + 'end_time' => '18:00:00', + ]); + + $farTimeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + 'date' => $farDate, + 'start_time' => '18:00:00', + 'end_time' => '02:00:00', + ]); + + $section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + ]); + + $nearShift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $nearTimeSlot->id, + 'title' => 'Ochtend Runner', + ]); + + $farShift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $farTimeSlot->id, + 'title' => 'Avond Tapper', + ]); + + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $farShift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $farTimeSlot->id, + ]); + + ShiftAssignment::factory()->approved()->create([ + 'shift_id' => $nearShift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $nearTimeSlot->id, + ]); + + Sanctum::actingAs($this->user); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.upcoming_shift.title', 'Ochtend Runner'); + $response->assertJsonPath('data.upcoming_shift.date', $nearDate); + } + + public function test_portal_me_ignores_cancelled_shift_assignments(): void + { + $futureDate = now()->addDays(7)->toDateString(); + + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + 'date' => $futureDate, + ]); + + $section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + ]); + + $shift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $timeSlot->id, + ]); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $this->person->id, + 'time_slot_id' => $timeSlot->id, + 'status' => ShiftAssignmentStatus::CANCELLED, + ]); + + Sanctum::actingAs($this->user); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.upcoming_shift', null); + } +}