feat: portal cross-event my-shifts endpoint and dashboard page

GET /portal/my-shifts aggregates shift assignments across all events
the logged-in user is linked to via Person records. Groups by event
then date, showing only active assignments (approved/pending_approval)
for approved/pending persons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 15:07:08 +02:00
parent d4004c798c
commit 53100d4f6d
7 changed files with 812 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Portal;
use App\Enums\CancellationSource;
use App\Enums\PersonStatus;
use App\Enums\ShiftAssignmentStatus;
use App\Http\Controllers\Controller;
use App\Models\Event;
@@ -23,6 +24,98 @@ final class PortalShiftController extends Controller
private readonly ShiftAssignmentService $shiftAssignmentService,
) {}
/**
* All shifts across all events for the logged-in user.
* Groups by event date, only includes active assignments.
*/
public function allMyShifts(Request $request): JsonResponse
{
$user = $request->user();
// Find all person records linked to this user (across all events).
// OrganisationScope is a no-op here since no org/event route param exists.
$personIds = Person::where('user_id', $user->id)
->whereIn('status', [
PersonStatus::APPROVED->value,
PersonStatus::PENDING->value,
])
->pluck('id');
if ($personIds->isEmpty()) {
return $this->success([]);
}
$assignments = ShiftAssignment::whereIn('person_id', $personIds)
->active()
->with([
'shift.festivalSection',
'shift.timeSlot',
'shift.location',
'person.event',
])
->get()
->sortBy(fn (ShiftAssignment $a) => $a->shift->timeSlot->date->format('Y-m-d') . ' ' .
Carbon::parse($a->shift->timeSlot->start_time)->format('H:i'))
->values();
$grouped = $assignments
->groupBy(fn (ShiftAssignment $a) => $a->person->event_id)
->map(function ($eventAssignments) {
$event = $eventAssignments->first()->person->event;
return [
'event' => [
'id' => $event->id,
'name' => $event->name,
'start_date' => $event->start_date->format('Y-m-d'),
'end_date' => $event->end_date->format('Y-m-d'),
],
'assignments' => $eventAssignments
->groupBy(fn (ShiftAssignment $a) => $a->shift->timeSlot->date->format('Y-m-d'))
->map(function ($dateAssignments, string $date) {
$carbonDate = Carbon::parse($date);
return [
'date' => $date,
'date_label' => ucfirst($carbonDate->translatedFormat('l j F')),
'shifts' => $dateAssignments->map(function (ShiftAssignment $a) {
$shift = $a->shift;
$timeSlot = $shift->timeSlot;
return [
'id' => $a->id,
'status' => $a->status->value,
'shift' => [
'id' => $shift->id,
'title' => $shift->title ?? $shift->festivalSection->name,
'section_name' => $shift->festivalSection->name,
'section_icon' => $shift->festivalSection->icon,
'time_slot_name' => $timeSlot->name,
'date' => $timeSlot->date->format('Y-m-d'),
'start_time' => Carbon::parse($shift->actual_start_time ?? $timeSlot->start_time)->format('H:i'),
'end_time' => Carbon::parse($shift->actual_end_time ?? $timeSlot->end_time)->format('H:i'),
'report_time' => $shift->report_time
? Carbon::parse($shift->report_time)->format('H:i')
: null,
'location' => $shift->location ? [
'name' => $shift->location->name,
'address' => $shift->location->address,
] : null,
],
];
})->values()->all(),
];
})
->values()
->all(),
];
})
->values()
->all();
return $this->success($grouped);
}
public function availableShifts(Request $request, Event $event): JsonResponse
{
$person = $this->resolvePerson($event);

View File

@@ -82,6 +82,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('portal/me', [PortalMeController::class, 'index']);
Route::put('portal/profile', [PortalMeController::class, 'updateProfile']);
Route::put('portal/password', [PortalMeController::class, 'updatePassword']);
Route::get('portal/my-shifts', [PortalShiftController::class, 'allMyShifts']);
Route::get('portal/events/{event}/available-shifts', [PortalShiftController::class, 'availableShifts']);
Route::get('portal/events/{event}/my-shifts', [PortalShiftController::class, 'myShifts']);
Route::post('portal/events/{event}/shifts/{shift}/claim', [PortalShiftController::class, 'claim']);

View File

@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Portal;
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 PortalAllMyShiftsTest extends TestCase
{
use RefreshDatabase;
private User $volunteer;
private Organisation $organisation;
private Event $event;
private FestivalSection $section;
private TimeSlot $timeSlot;
private CrowdType $crowdType;
private Person $person;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->organisation = Organisation::factory()->create();
$this->volunteer = User::factory()->create();
$this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']);
$this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
$this->section = FestivalSection::factory()->create([
'event_id' => $this->event->id,
]);
$this->timeSlot = TimeSlot::factory()->create([
'event_id' => $this->event->id,
'person_type' => 'VOLUNTEER',
'date' => now()->addMonth(),
]);
$this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
'organisation_id' => $this->organisation->id,
]);
$this->person = Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
'user_id' => $this->volunteer->id,
]);
}
private function createShiftWithAssignment(array $shiftOverrides = [], array $assignmentOverrides = []): ShiftAssignment
{
$shift = Shift::factory()->open()->create(array_merge([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'slots_open_for_claiming' => 3,
], $shiftOverrides));
// Use approved() only when no explicit status override is given,
// because approved() uses afterCreating() which would override create() attributes.
$factory = ShiftAssignment::factory();
if (! array_key_exists('status', $assignmentOverrides)) {
$factory = $factory->approved();
}
return $factory->create(array_merge([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
], $assignmentOverrides));
}
// =========================================================================
// Happy path
// =========================================================================
public function test_returns_shifts_for_linked_persons(): void
{
$this->createShiftWithAssignment(['title' => 'Tapper']);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => [
'event' => ['id', 'name', 'start_date', 'end_date'],
'assignments' => [
'*' => [
'date',
'date_label',
'shifts' => [
'*' => [
'id',
'status',
'shift' => [
'id',
'title',
'section_name',
'section_icon',
'time_slot_name',
'date',
'start_time',
'end_time',
'report_time',
'location',
],
],
],
],
],
],
],
]);
$this->assertCount(1, $response->json('data'));
$this->assertEquals($this->event->id, $response->json('data.0.event.id'));
$this->assertEquals('Tapper', $response->json('data.0.assignments.0.shifts.0.shift.title'));
}
// =========================================================================
// Empty results
// =========================================================================
public function test_returns_empty_when_user_has_no_linked_persons(): void
{
$otherUser = User::factory()->create();
Sanctum::actingAs($otherUser);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk()
->assertJsonPath('data', []);
}
public function test_returns_empty_when_no_active_assignments(): void
{
// Person exists but no assignments
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk()
->assertJsonPath('data', []);
}
// =========================================================================
// Status filtering
// =========================================================================
public function test_only_returns_approved_and_pending_approval_assignments(): void
{
// Approved — should appear
$this->createShiftWithAssignment(
['title' => 'Approved Shift'],
['status' => ShiftAssignmentStatus::APPROVED],
);
// Pending approval — should appear
$this->createShiftWithAssignment(
['title' => 'Pending Shift'],
['status' => ShiftAssignmentStatus::PENDING_APPROVAL],
);
// Cancelled — should NOT appear
$this->createShiftWithAssignment(
['title' => 'Cancelled Shift'],
['status' => ShiftAssignmentStatus::CANCELLED],
);
// Rejected — should NOT appear
$this->createShiftWithAssignment(
['title' => 'Rejected Shift'],
['status' => ShiftAssignmentStatus::REJECTED],
);
// Completed — should NOT appear
$this->createShiftWithAssignment(
['title' => 'Completed Shift'],
['status' => ShiftAssignmentStatus::COMPLETED],
);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk();
$allShifts = collect($response->json('data'))
->flatMap(fn ($e) => collect($e['assignments']))
->flatMap(fn ($d) => $d['shifts']);
$this->assertCount(2, $allShifts);
$statuses = $allShifts->pluck('status')->sort()->values()->all();
$this->assertEquals(['approved', 'pending_approval'], $statuses);
}
public function test_excludes_persons_with_rejected_status(): void
{
// Create a rejected person with an approved assignment
$rejectedPerson = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
'user_id' => $this->volunteer->id,
'status' => 'rejected',
]);
$shift = Shift::factory()->open()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'slots_open_for_claiming' => 3,
]);
ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'person_id' => $rejectedPerson->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk();
// Only the approved person should contribute shifts
$allShifts = collect($response->json('data'))
->flatMap(fn ($e) => collect($e['assignments']))
->flatMap(fn ($d) => $d['shifts']);
$this->assertCount(0, $allShifts);
}
// =========================================================================
// Grouping
// =========================================================================
public function test_grouped_by_event_and_date(): void
{
// Create a second event with its own person and assignment
$event2 = Event::factory()->create(['organisation_id' => $this->organisation->id]);
$section2 = FestivalSection::factory()->create(['event_id' => $event2->id]);
$timeSlot2 = TimeSlot::factory()->create([
'event_id' => $event2->id,
'person_type' => 'VOLUNTEER',
'date' => now()->addMonths(2),
]);
$crowdType2 = CrowdType::factory()->systemType('VOLUNTEER')->create([
'organisation_id' => $this->organisation->id,
]);
$person2 = Person::factory()->approved()->create([
'event_id' => $event2->id,
'crowd_type_id' => $crowdType2->id,
'user_id' => $this->volunteer->id,
]);
// Assignment in event 1
$this->createShiftWithAssignment(['title' => 'Event 1 Shift']);
// Assignment in event 2
$shift2 = Shift::factory()->open()->create([
'festival_section_id' => $section2->id,
'time_slot_id' => $timeSlot2->id,
'slots_total' => 4,
'slots_open_for_claiming' => 3,
'title' => 'Event 2 Shift',
]);
ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift2->id,
'person_id' => $person2->id,
'time_slot_id' => $timeSlot2->id,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk();
// Should have 2 event groups
$this->assertCount(2, $response->json('data'));
$eventIds = collect($response->json('data'))->pluck('event.id')->sort()->values()->all();
$this->assertContains($this->event->id, $eventIds);
$this->assertContains($event2->id, $eventIds);
}
public function test_multiple_dates_within_same_event(): void
{
$timeSlot2 = TimeSlot::factory()->create([
'event_id' => $this->event->id,
'person_type' => 'VOLUNTEER',
'date' => now()->addMonths(2),
]);
// Day 1 shift
$this->createShiftWithAssignment(['title' => 'Day 1 Shift']);
// Day 2 shift
$shift2 = Shift::factory()->open()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $timeSlot2->id,
'slots_total' => 4,
'slots_open_for_claiming' => 3,
'title' => 'Day 2 Shift',
]);
ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift2->id,
'person_id' => $this->person->id,
'time_slot_id' => $timeSlot2->id,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk();
// 1 event group with 2 date groups
$this->assertCount(1, $response->json('data'));
$this->assertCount(2, $response->json('data.0.assignments'));
}
// =========================================================================
// Authentication
// =========================================================================
public function test_unauthenticated_returns_401(): void
{
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertUnauthorized();
}
// =========================================================================
// Response data
// =========================================================================
public function test_shift_includes_location_data(): void
{
$location = Location::factory()->create([
'event_id' => $this->event->id,
'name' => 'Hoofdpodium',
'address' => 'Festivalplein 1',
]);
$this->createShiftWithAssignment([
'title' => 'Shift met locatie',
'location_id' => $location->id,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk();
$shiftData = $response->json('data.0.assignments.0.shifts.0.shift');
$this->assertEquals('Hoofdpodium', $shiftData['location']['name']);
$this->assertEquals('Festivalplein 1', $shiftData['location']['address']);
}
public function test_shift_without_location_returns_null(): void
{
$this->createShiftWithAssignment([
'title' => 'Shift zonder locatie',
'location_id' => null,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk();
$this->assertNull($response->json('data.0.assignments.0.shifts.0.shift.location'));
}
public function test_shift_title_falls_back_to_section_name(): void
{
$this->createShiftWithAssignment(['title' => null]);
Sanctum::actingAs($this->volunteer);
$response = $this->getJson('/api/v1/portal/my-shifts');
$response->assertOk();
$shiftTitle = $response->json('data.0.assignments.0.shifts.0.shift.title');
$this->assertEquals($this->section->name, $shiftTitle);
}
}