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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 07:39:19 +02:00
parent 02c4b4fd5f
commit 6250421355
2 changed files with 308 additions and 1 deletions

View File

@@ -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);
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Enums\ShiftAssignmentStatus;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Location;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\TimeSlot;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class PortalMeUpcomingShiftTest extends TestCase
{
use RefreshDatabase;
private Organisation $organisation;
private Event $event;
private CrowdType $volunteerCrowdType;
private User $user;
private Person $person;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}