From 53100d4f6de9987e5fe0b68cb00c38b26b48abff Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 14 Apr 2026 15:07:08 +0200 Subject: [PATCH] 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) --- .../Api/V1/Portal/PortalShiftController.php | 93 ++++ api/routes/api.php | 1 + .../Api/V1/Portal/PortalAllMyShiftsTest.php | 409 ++++++++++++++++++ .../src/composables/api/usePortalShifts.ts | 15 +- apps/portal/src/pages/shifts/index.vue | 225 +++++++++- apps/portal/src/types/portal-shift.ts | 37 ++ dev-docs/API.md | 41 ++ 7 files changed, 812 insertions(+), 9 deletions(-) create mode 100644 api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php diff --git a/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php b/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php index 92560744..e980c43c 100644 --- a/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php +++ b/api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php @@ -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); diff --git a/api/routes/api.php b/api/routes/api.php index 452cc7e7..15e45a9c 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -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']); diff --git a/api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php b/api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php new file mode 100644 index 00000000..c1a700ee --- /dev/null +++ b/api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php @@ -0,0 +1,409 @@ +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); + } +} diff --git a/apps/portal/src/composables/api/usePortalShifts.ts b/apps/portal/src/composables/api/usePortalShifts.ts index 4aedb49b..a76d071a 100644 --- a/apps/portal/src/composables/api/usePortalShifts.ts +++ b/apps/portal/src/composables/api/usePortalShifts.ts @@ -1,12 +1,25 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' -import type { AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift' +import type { AllMyShiftsEventGroup, AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift' interface ApiResponse { data: T } +export function useAllMyShifts() { + return useQuery({ + queryKey: ['portal-all-my-shifts'], + queryFn: async () => { + const { data } = await apiClient.get>( + '/portal/my-shifts', + ) + + return data.data + }, + }) +} + export function useAvailableShifts(eventId: Ref) { return useQuery({ queryKey: ['available-shifts', eventId], diff --git a/apps/portal/src/pages/shifts/index.vue b/apps/portal/src/pages/shifts/index.vue index f0d03537..02bedc8a 100644 --- a/apps/portal/src/pages/shifts/index.vue +++ b/apps/portal/src/pages/shifts/index.vue @@ -1,4 +1,8 @@ + + diff --git a/apps/portal/src/types/portal-shift.ts b/apps/portal/src/types/portal-shift.ts index c7e7ba61..81cf9ee0 100644 --- a/apps/portal/src/types/portal-shift.ts +++ b/apps/portal/src/types/portal-shift.ts @@ -48,3 +48,40 @@ export interface MyShiftAssignment { report_time: string | null can_cancel: boolean } + +// Cross-event "all my shifts" types +export interface AllMyShiftsAssignment { + id: string + status: string + shift: { + id: string + title: string + section_name: string + section_icon: string | null + time_slot_name: string + date: string + start_time: string + end_time: string + report_time: string | null + location: { + name: string + address: string | null + } | null + } +} + +export interface AllMyShiftsDateGroup { + date: string + date_label: string + shifts: AllMyShiftsAssignment[] +} + +export interface AllMyShiftsEventGroup { + event: { + id: string + name: string + start_date: string + end_date: string + } + assignments: AllMyShiftsDateGroup[] +} diff --git a/dev-docs/API.md b/dev-docs/API.md index a878ad30..c0054b4a 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -538,6 +538,47 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al - `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid. - `GET /portal/me` — auth:sanctum. Returns the authenticated user's person record for a given event. Query param: `event_id` (required, ULID). Resolves sub-events to parent festival. Returns `PersonResource` with crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found. +- `GET /portal/my-shifts` — auth:sanctum. Returns all active shift assignments across all events for the authenticated user. Finds all Person records linked via `user_id` (approved/pending status), then returns their active assignments (approved/pending_approval). Response grouped by event → date. +- `GET /portal/events/{event}/available-shifts` — auth:sanctum. Returns shifts available to claim, grouped by date → time slot. Requires approved person status. +- `GET /portal/events/{event}/my-shifts` — auth:sanctum. Returns the user's shift assignments for a specific event, categorized as upcoming/past/cancelled. +- `POST /portal/events/{event}/shifts/{shift}/claim` — auth:sanctum. Claim a shift. Returns assignment with status (pending_approval or approved based on section auto-accept). +- `POST /portal/events/{event}/assignments/{shiftAssignment}/cancel` — auth:sanctum. Cancel own assignment. Must be future and cancellable status. + +### Portal My-Shifts Response + +```json +{ + "data": [ + { + "event": { "id": "ulid", "name": "Festival X", "start_date": "2026-07-01", "end_date": "2026-07-03" }, + "assignments": [ + { + "date": "2026-07-01", + "date_label": "Woensdag 1 juli", + "shifts": [ + { + "id": "ulid", + "status": "approved", + "shift": { + "id": "ulid", + "title": "Tapper", + "section_name": "Bar", + "section_icon": "tabler-beer", + "time_slot_name": "Avond", + "date": "2026-07-01", + "start_time": "18:00", + "end_time": "23:00", + "report_time": "17:30", + "location": { "name": "Hoofdpodium", "address": "Festivalplein 1" } + } + } + ] + } + ] + } + ] +} +``` ## Registration Field Templates (Organisation Settings)