diff --git a/api/app/Http/Controllers/Api/V1/FestivalSectionController.php b/api/app/Http/Controllers/Api/V1/FestivalSectionController.php index 8927e806..300cdf28 100644 --- a/api/app/Http/Controllers/Api/V1/FestivalSectionController.php +++ b/api/app/Http/Controllers/Api/V1/FestivalSectionController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\ReorderFestivalSectionsRequest; use App\Http\Requests\Api\V1\StoreFestivalSectionRequest; use App\Http\Requests\Api\V1\UpdateFestivalSectionRequest; +use App\Http\Requests\Api\V1\UpdateRegistrationSettingsRequest; use App\Http\Resources\Api\V1\FestivalSectionResource; use App\Models\Event; use App\Models\FestivalSection; @@ -94,6 +95,82 @@ final class FestivalSectionController extends Controller return response()->json(null, 204); } + public function registrationSettings(Event $event): JsonResponse + { + Gate::authorize('viewAny', [FestivalSection::class, $event]); + + $sections = $this->getFestivalSections($event); + + $grouped = $sections->groupBy('name')->map(function ($group) { + $first = $group->first(); + + return [ + 'name' => $first->name, + 'category' => $first->category, + 'icon' => $first->icon, + 'show_in_registration' => $group->contains('show_in_registration', true), + 'registration_description' => $group->whereNotNull('registration_description')->first()?->registration_description, + 'section_count' => $group->count(), + 'section_ids' => $group->pluck('id')->values()->toArray(), + ]; + })->values(); + + return response()->json(['data' => $grouped]); + } + + public function updateRegistrationSettings(UpdateRegistrationSettingsRequest $request, Event $event): JsonResponse + { + Gate::authorize('create', [FestivalSection::class, $event]); + + $validated = $request->validated(); + $sections = $this->getFestivalSections($event); + + $matching = $sections->where('name', $validated['name']); + + if ($matching->isEmpty()) { + return $this->error('Sectie niet gevonden.', 404); + } + + FestivalSection::whereIn('id', $matching->pluck('id')) + ->update([ + 'show_in_registration' => $validated['show_in_registration'], + 'registration_description' => $validated['registration_description'], + ]); + + activity('section_management') + ->performedOn($event) + ->causedBy(auth()->user()) + ->withProperties([ + 'section_name' => $validated['name'], + 'show_in_registration' => $validated['show_in_registration'], + 'sections_updated' => $matching->count(), + ]) + ->log('section.registration_settings_updated'); + + // Return updated settings + return $this->registrationSettings($event); + } + + /** + * Get all sections across the festival context (parent + children). + */ + private function getFestivalSections(Event $event): \Illuminate\Support\Collection + { + $eventIds = collect([$event->id]); + + if ($event->isSubEvent()) { + $parentId = $event->parent_event_id; + $eventIds = Event::where('parent_event_id', $parentId) + ->orWhere('id', $parentId) + ->pluck('id'); + } elseif ($event->hasChildren()) { + $childIds = $event->children()->pluck('id'); + $eventIds = $childIds->push($event->id); + } + + return FestivalSection::whereIn('event_id', $eventIds)->ordered()->get(); + } + public function reorder(ReorderFestivalSectionsRequest $request, Event $event): JsonResponse { Gate::authorize('reorder', [FestivalSection::class, $event]); diff --git a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php index 8b95a273..912896c9 100644 --- a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php +++ b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php @@ -24,26 +24,30 @@ final class PublicRegistrationDataController extends Controller $festivalEvent = $event->isSubEvent() ? $event->parent : $event; - $sectionQuery = FestivalSection::where('event_id', $festivalEvent->id) - ->where(function ($query) { - $query->where('type', '!=', 'cross_event') - ->orWhereNull('type'); - }) - ->ordered(); + if ($festivalEvent->isFestival() || $festivalEvent->hasChildren()) { + // Festival: get child event sections only (skip parent operational sections) + $childIds = Event::where('parent_event_id', $festivalEvent->id)->pluck('id'); - if ($festivalEvent->isFestival()) { - $childIds = $festivalEvent->children()->pluck('id'); - $sectionQuery->orWhere(function ($query) use ($childIds) { - $query->whereIn('event_id', $childIds) - ->where(function ($q) { - $q->where('type', '!=', 'cross_event') - ->orWhereNull('type'); - }); - }); + $sections = FestivalSection::whereIn('event_id', $childIds) + ->where('show_in_registration', true) + ->where('type', 'standard') + ->select('id', 'name', 'category', 'icon', 'registration_description') + ->orderBy('category') + ->orderBy('sort_order') + ->get() + ->unique('name') + ->values(); + } else { + // Flat event: all sections of the event + $sections = FestivalSection::where('event_id', $festivalEvent->id) + ->where('show_in_registration', true) + ->where('type', 'standard') + ->select('id', 'name', 'category', 'icon', 'registration_description') + ->orderBy('category') + ->orderBy('sort_order') + ->get(); } - $sections = $sectionQuery->get(['id', 'name', 'category', 'icon']); - $timeSlots = $festivalEvent->getAllRelevantTimeSlots() ->where('person_type', 'VOLUNTEER') ->values(); @@ -62,6 +66,7 @@ final class PublicRegistrationDataController extends Controller 'name' => $section->name, 'category' => $section->category, 'icon' => $section->icon, + 'registration_description' => $section->registration_description, ]), 'time_slots' => $timeSlots->map(fn (TimeSlot $slot) => [ 'id' => $slot->id, diff --git a/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php b/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php index 82c1f65a..a419c619 100644 --- a/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php @@ -31,6 +31,8 @@ final class StoreFestivalSectionRequest extends FormRequest 'crew_accreditation_level' => ['nullable', 'string', 'max:50'], 'public_form_accreditation_level' => ['nullable', 'string', 'max:50'], 'timed_accreditations' => ['nullable', 'boolean'], + 'show_in_registration' => ['nullable', 'boolean'], + 'registration_description' => ['nullable', 'string', 'max:500'], ]; } diff --git a/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php b/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php index ca1453fa..0eb4c507 100644 --- a/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php @@ -30,6 +30,8 @@ final class UpdateFestivalSectionRequest extends FormRequest 'timed_accreditations' => ['sometimes', 'boolean'], 'crew_accreditation_level' => ['nullable', 'string', 'max:50'], 'public_form_accreditation_level' => ['nullable', 'string', 'max:50'], + 'show_in_registration' => ['sometimes', 'boolean'], + 'registration_description' => ['sometimes', 'nullable', 'string', 'max:500'], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationSettingsRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationSettingsRequest.php new file mode 100644 index 00000000..bf70273b --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateRegistrationSettingsRequest.php @@ -0,0 +1,25 @@ + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'show_in_registration' => ['required', 'boolean'], + 'registration_description' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php new file mode 100644 index 00000000..83d2afb5 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php @@ -0,0 +1,55 @@ +user(); + + if ($user) { + $this->merge([ + 'name' => $user->name, + 'email' => $user->email, + '_authenticated' => true, + ]); + } + } + + /** @return array */ + public function rules(): array + { + return [ + 'name' => ['required_without:_authenticated', 'string', 'max:255'], + 'email' => ['required_without:_authenticated', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:50'], + + 'tshirt_size' => ['nullable', 'string', 'in:XS,S,M,L,XL,XXL,XXXL'], + 'first_aid' => ['nullable', 'boolean'], + 'allergies' => ['nullable', 'string', 'max:500'], + 'access_requirements' => ['nullable', 'string', 'max:500'], + 'driving_licence' => ['nullable', 'boolean'], + + 'motivation' => ['nullable', 'string', 'max:1000'], + 'motivation_other' => ['nullable', 'string', 'max:500'], + + 'section_preferences' => ['nullable', 'array', 'max:5'], + 'section_preferences.*.section_name' => ['required', 'string', 'max:255'], + 'section_preferences.*.priority' => ['required', 'integer', 'min:1', 'max:5'], + + 'availabilities' => ['nullable', 'array'], + 'availabilities.*.time_slot_id' => ['required', 'ulid', 'exists:time_slots,id'], + 'availabilities.*.preference_level' => ['nullable', 'integer', 'min:1', 'max:5'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/FestivalSectionResource.php b/api/app/Http/Resources/Api/V1/FestivalSectionResource.php index 7277d70e..861e7efc 100644 --- a/api/app/Http/Resources/Api/V1/FestivalSectionResource.php +++ b/api/app/Http/Resources/Api/V1/FestivalSectionResource.php @@ -27,6 +27,8 @@ final class FestivalSectionResource extends JsonResource 'crew_accreditation_level' => $this->crew_accreditation_level, 'public_form_accreditation_level' => $this->public_form_accreditation_level, 'timed_accreditations' => $this->timed_accreditations, + 'show_in_registration' => $this->show_in_registration, + 'registration_description' => $this->registration_description, 'created_at' => $this->created_at->toIso8601String(), 'shifts_count' => $this->whenCounted('shifts'), ]; diff --git a/api/app/Models/FestivalSection.php b/api/app/Models/FestivalSection.php index 851e1210..2763b56d 100644 --- a/api/app/Models/FestivalSection.php +++ b/api/app/Models/FestivalSection.php @@ -33,6 +33,8 @@ final class FestivalSection extends Model 'crew_accreditation_level', 'public_form_accreditation_level', 'timed_accreditations', + 'show_in_registration', + 'registration_description', ]; protected function casts(): array @@ -43,6 +45,7 @@ final class FestivalSection extends Model 'added_to_timeline' => 'boolean', 'responder_self_checkin' => 'boolean', 'timed_accreditations' => 'boolean', + 'show_in_registration' => 'boolean', ]; } diff --git a/api/app/Services/VolunteerRegistrationService.php b/api/app/Services/VolunteerRegistrationService.php new file mode 100644 index 00000000..72ca30ed --- /dev/null +++ b/api/app/Services/VolunteerRegistrationService.php @@ -0,0 +1,193 @@ + $validated + * + * @throws ValidationException + */ + public function register(Event $event, array $validated, ?User $user): Person + { + if ($event->status !== 'registration_open') { + throw ValidationException::withMessages([ + 'event' => ['This event is not accepting registrations.'], + ]); + } + + $festivalEvent = $this->resolveFestivalEvent($event); + $email = $user?->email ?? $validated['email']; + + $this->checkDuplicateRegistration($festivalEvent, $email); + + $volunteerCrowdType = $this->resolveVolunteerCrowdType($event); + + return DB::transaction(function () use ($festivalEvent, $validated, $user, $email, $volunteerCrowdType): Person { + $person = Person::updateOrCreate( + [ + 'event_id' => $festivalEvent->id, + 'email' => $email, + ], + [ + 'user_id' => $user?->id, + 'crowd_type_id' => $volunteerCrowdType->id, + 'name' => $user?->name ?? $validated['name'], + 'phone' => $validated['phone'] ?? null, + 'status' => PersonStatus::PENDING, + 'custom_fields' => [ + 'tshirt_size' => $validated['tshirt_size'] ?? null, + 'first_aid' => $validated['first_aid'] ?? false, + 'allergies' => $validated['allergies'] ?? null, + 'access_requirements' => $validated['access_requirements'] ?? null, + 'driving_licence' => $validated['driving_licence'] ?? false, + 'motivation' => $validated['motivation'] ?? null, + 'motivation_other' => $validated['motivation_other'] ?? null, + 'section_preferences' => collect($validated['section_preferences'] ?? []) + ->map(fn ($pref) => [ + 'section_name' => $pref['section_name'], + 'priority' => $pref['priority'], + ])->toArray(), + ], + ] + ); + + $this->syncAvailabilities($person, $festivalEvent, $validated['availabilities'] ?? []); + + if ($user === null) { + $this->detectIdentityMatch($person); + } + + $source = $user !== null ? 'authenticated_form' : 'public_form'; + + $activityLogger = activity('volunteer_registration') + ->performedOn($person) + ->withProperties([ + 'source' => $source, + 'event_id' => $festivalEvent->id, + 'person_id' => $person->id, + 'email' => $email, + ]); + + if ($user !== null) { + $activityLogger->causedBy($user); + } + + $activityLogger->log('person.registered'); + + return $person; + }); + } + + private function resolveFestivalEvent(Event $event): Event + { + if ($event->isSubEvent()) { + return $event->parent; + } + + return $event; + } + + /** + * @throws ValidationException + */ + private function checkDuplicateRegistration(Event $festivalEvent, string $email): void + { + $existing = Person::where('event_id', $festivalEvent->id) + ->where('email', $email) + ->first(); + + if ($existing === null) { + return; + } + + if ($existing->status !== PersonStatus::REJECTED) { + throw ValidationException::withMessages([ + 'email' => ['Already registered for this event.'], + ]); + } + } + + /** + * @throws \RuntimeException + */ + private function resolveVolunteerCrowdType(Event $event): CrowdType + { + $crowdType = CrowdType::where('organisation_id', $event->organisation_id) + ->where('system_type', 'VOLUNTEER') + ->first(); + + if ($crowdType === null) { + Log::error('No volunteer crowd type configured', [ + 'organisation_id' => $event->organisation_id, + 'event_id' => $event->id, + ]); + + abort(500, 'No volunteer crowd type configured for this organisation.'); + } + + return $crowdType; + } + + /** + * @param array> $availabilities + */ + private function syncAvailabilities(Person $person, Event $festivalEvent, array $availabilities): void + { + if (empty($availabilities)) { + return; + } + + VolunteerAvailability::where('person_id', $person->id)->delete(); + + $validTimeSlotIds = $festivalEvent->getAllRelevantTimeSlots() + ->where('person_type', 'VOLUNTEER') + ->pluck('id') + ->toArray(); + + foreach ($availabilities as $availability) { + if (! in_array($availability['time_slot_id'], $validTimeSlotIds, true)) { + continue; + } + + VolunteerAvailability::create([ + 'person_id' => $person->id, + 'time_slot_id' => $availability['time_slot_id'], + 'preference_level' => $availability['preference_level'] ?? 3, + 'submitted_at' => now(), + ]); + } + } + + private function detectIdentityMatch(Person $person): void + { + if (! Schema::hasTable('person_identity_matches')) { + activity('volunteer_registration') + ->performedOn($person) + ->withProperties(['email' => $person->email]) + ->log('person.identity_match_skipped_table_missing'); + + return; + } + + $this->identityService->detectMatchForPerson($person); + } +} diff --git a/api/database/migrations/2026_04_10_300000_add_registration_fields_to_festival_sections_table.php b/api/database/migrations/2026_04_10_300000_add_registration_fields_to_festival_sections_table.php new file mode 100644 index 00000000..2bd869a5 --- /dev/null +++ b/api/database/migrations/2026_04_10_300000_add_registration_fields_to_festival_sections_table.php @@ -0,0 +1,25 @@ +boolean('show_in_registration')->default(false)->after('timed_accreditations'); + $table->text('registration_description')->nullable()->after('show_in_registration'); + }); + } + + public function down(): void + { + Schema::table('festival_sections', function (Blueprint $table) { + $table->dropColumn(['show_in_registration', 'registration_description']); + }); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 290f990b..edea6b8f 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -247,34 +247,38 @@ class DevSeeder extends Seeder 'event_id' => $festival->id, 'name' => 'EHBO', 'type' => 'cross_event', 'category' => 'Veiligheid', 'icon' => 'tabler-first-aid-kit', 'sort_order' => 1, 'responder_self_checkin' => true, 'crew_auto_accepts' => false, + 'show_in_registration' => false, ]); $nachtsecurity = FestivalSection::create([ 'event_id' => $festival->id, 'name' => 'Nachtsecurity', 'type' => 'standard', 'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 2, 'responder_self_checkin' => true, 'crew_auto_accepts' => false, + 'show_in_registration' => false, ]); $terreinploeg = FestivalSection::create([ 'event_id' => $festival->id, 'name' => 'Terreinploeg', 'type' => 'standard', 'category' => 'Productie', 'icon' => 'tabler-shovel', 'sort_order' => 3, 'responder_self_checkin' => true, 'crew_auto_accepts' => true, + 'show_in_registration' => false, ]); $accreditatiebalie = FestivalSection::create([ 'event_id' => $festival->id, 'name' => 'Accreditatiebalie', 'type' => 'cross_event', 'category' => 'Ontvangst', 'icon' => 'tabler-id-badge', 'sort_order' => 4, 'responder_self_checkin' => true, 'crew_auto_accepts' => true, + 'show_in_registration' => false, ]); // ── Sub-event sections (5 per sub-event) ── $sectionDefs = [ - 'hoofdbar' => ['name' => 'Hoofdpodium Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true], - 'theaterbar' => ['name' => 'Theatertent Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true], - 'hospitality' => ['name' => 'Backstage Hospitality', 'category' => 'Hospitality', 'icon' => 'tabler-armchair', 'crew_auto_accepts' => false], - 'podiumtechniek' => ['name' => 'Podiumtechniek', 'category' => 'Techniek', 'icon' => 'tabler-speakerphone', 'crew_auto_accepts' => false], - 'ingang' => ['name' => 'Ingang & Tickets', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'crew_auto_accepts' => true], + 'hoofdbar' => ['name' => 'Hoofdpodium Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Tap bier en drankjes voor festivalgangers bij het hoofdpodium'], + 'theaterbar' => ['name' => 'Theatertent Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Bediening in de overdekte theatertent'], + 'hospitality' => ['name' => 'Backstage Hospitality', 'category' => 'Hospitality', 'icon' => 'tabler-armchair', 'crew_auto_accepts' => false, 'show_in_registration' => true, 'registration_description' => 'Ontvang en begeleid artiesten en gasten backstage'], + 'podiumtechniek' => ['name' => 'Podiumtechniek', 'category' => 'Techniek', 'icon' => 'tabler-speakerphone', 'crew_auto_accepts' => false, 'show_in_registration' => true, 'registration_description' => 'Help met geluid- en lichttechniek bij de podia'], + 'ingang' => ['name' => 'Ingang & Tickets', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Verwelkom bezoekers en scan tickets bij de ingang'], ]; $sections = []; @@ -290,6 +294,8 @@ class DevSeeder extends Seeder 'sort_order' => $order++, 'responder_self_checkin' => true, 'crew_auto_accepts' => $def['crew_auto_accepts'], + 'show_in_registration' => $def['show_in_registration'] ?? false, + 'registration_description' => $def['registration_description'] ?? null, ]); } } @@ -598,8 +604,67 @@ class DevSeeder extends Seeder $usedPersonSlots[$a['person']->id][] = $shift->time_slot_id; } + // ── Intentional overbooking for UI testing ── + + $overbookPersons = Person::where('event_id', $festival->id) + ->where('status', 'approved') + ->whereNotIn('id', array_keys($usedPersonSlots)) + ->limit(20) + ->get(); + + // EHBO Vrijdag Early: slots_total=3, target 4 approved (1 over) + // Already has 1 named approved (Ahmed). Add 3 more = 4 total. + $ehboOverbookTarget = 3; + $ehboShift = $s['ehbo_vr_early']; + foreach ($overbookPersons->splice(0, $ehboOverbookTarget) as $person) { + ShiftAssignment::create([ + 'shift_id' => $ehboShift->id, + 'person_id' => $person->id, + 'time_slot_id' => $ehboShift->time_slot_id, + 'status' => ShiftAssignmentStatus::APPROVED, + 'auto_approved' => false, + 'assigned_at' => now(), + 'approved_at' => now(), + ]); + $usedPersonSlots[$person->id][] = $ehboShift->time_slot_id; + } + + // Terreinploeg Opbouw Dag 1: slots_total=15, target 17 approved (2 over) + // Already has 1 named approved (Lotte). Add 16 more = 17 total. + $terreinOverbookTarget = 16; + $terreinShift = $s['terrein_opbouw1']; + $terreinOverbookPersons = Person::where('event_id', $festival->id) + ->where('status', 'approved') + ->whereNotIn('id', collect($usedPersonSlots)->keys()->filter( + fn ($pid) => in_array($terreinShift->time_slot_id, $usedPersonSlots[$pid] ?? []), + )->values()) + ->limit($terreinOverbookTarget) + ->get(); + foreach ($terreinOverbookPersons as $person) { + ShiftAssignment::create([ + 'shift_id' => $terreinShift->id, + 'person_id' => $person->id, + 'time_slot_id' => $terreinShift->time_slot_id, + 'status' => ShiftAssignmentStatus::APPROVED, + 'auto_approved' => true, + 'assigned_at' => now(), + 'approved_at' => now(), + ]); + $usedPersonSlots[$person->id][] = $terreinShift->time_slot_id; + } + // ── Factory shift assignments (~100) ── + // Track filled (approved/completed) counts per shift to respect slots_total + $shiftFilledCounts = []; + $existingAssignments = ShiftAssignment::whereIn('shift_id', collect($allShifts)->pluck('id')) + ->whereIn('status', [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED]) + ->get() + ->groupBy('shift_id'); + foreach ($allShifts as $shift) { + $shiftFilledCounts[$shift->id] = ($existingAssignments[$shift->id] ?? collect())->count(); + } + $approvedPersons = Person::where('event_id', $festival->id) ->where('status', 'approved') ->get(); @@ -631,6 +696,11 @@ class DevSeeder extends Seeder $statusIdx++; $isApproved = in_array($status, [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED]); + // Skip approved assignments if shift is already at capacity + if ($isApproved && ($shiftFilledCounts[$shift->id] ?? 0) >= $shift->slots_total) { + continue; + } + ShiftAssignment::create([ 'shift_id' => $shift->id, 'person_id' => $person->id, @@ -641,6 +711,11 @@ class DevSeeder extends Seeder 'approved_at' => $isApproved ? now() : null, 'rejection_reason' => $status === ShiftAssignmentStatus::REJECTED ? 'Geen beschikbare plek' : null, ]); + + if ($isApproved) { + $shiftFilledCounts[$shift->id] = ($shiftFilledCounts[$shift->id] ?? 0) + 1; + } + $existing[] = $shift->time_slot_id; } $usedPersonSlots[$person->id] = $existing; @@ -840,9 +915,9 @@ class DevSeeder extends Seeder // ── Sections, time slots, shifts per sub-event ── $sectionDefs = [ - ['name' => 'Schaatsbaan Bar', 'category' => 'Bar', 'icon' => 'tabler-beer'], - ['name' => 'Schaatsverhuur', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket'], - ['name' => 'Terrein', 'category' => 'Productie', 'icon' => 'tabler-shovel'], + ['name' => 'Schaatsbaan Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'show_in_registration' => true, 'registration_description' => 'Warme dranken en snacks serveren aan schaatsers'], + ['name' => 'Schaatsverhuur', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'show_in_registration' => true, 'registration_description' => 'Schaatsen uitgeven en innemen bij de verhuurbalie'], + ['name' => 'Terrein', 'category' => 'Productie', 'icon' => 'tabler-shovel', 'show_in_registration' => true, 'registration_description' => 'IJsbaan onderhoud en terreinbeheer'], ]; $allShifts = []; @@ -863,6 +938,8 @@ class DevSeeder extends Seeder 'sort_order' => $order + 1, 'responder_self_checkin' => true, 'crew_auto_accepts' => true, + 'show_in_registration' => $sd['show_in_registration'] ?? false, + 'registration_description' => $sd['registration_description'] ?? null, ]); } @@ -905,9 +982,10 @@ class DevSeeder extends Seeder $draftShifts = collect($allShifts)->filter(fn (Shift $shift) => $shift->status === 'draft'); $allApproved = $approvedVol->merge($approvedCrew); - // Week 1+2: 5-6 per open shift + // Week 1+2: up to slots_total per open shift foreach ($openShifts as $shift) { - $assigned = $allApproved->shuffle()->take(rand(5, 6)); + $count = min(rand(5, 6), $shift->slots_total); + $assigned = $allApproved->shuffle()->take($count); foreach ($assigned as $person) { ShiftAssignment::create([ 'shift_id' => $shift->id, @@ -981,10 +1059,10 @@ class DevSeeder extends Seeder // ── Sections (4) ── - $secPodium = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Podium Erasmusbrug', 'type' => 'standard', 'category' => 'Podium', 'icon' => 'tabler-microphone-2', 'sort_order' => 1, 'responder_self_checkin' => true]); - $secBar = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Bar Willemsplein', 'type' => 'standard', 'category' => 'Bar', 'icon' => 'tabler-beer', 'sort_order' => 2, 'responder_self_checkin' => true, 'crew_auto_accepts' => true]); - $secKids = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Kinderactiviteiten', 'type' => 'standard', 'category' => 'Entertainment', 'icon' => 'tabler-balloon', 'sort_order' => 3, 'responder_self_checkin' => true]); - $secBev = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Beveiliging', 'type' => 'standard', 'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 4, 'responder_self_checkin' => true]); + $secPodium = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Podium Erasmusbrug', 'type' => 'standard', 'category' => 'Podium', 'icon' => 'tabler-microphone-2', 'sort_order' => 1, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Podiummedewerker bij de Erasmusbrug']); + $secBar = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Bar Willemsplein', 'type' => 'standard', 'category' => 'Bar', 'icon' => 'tabler-beer', 'sort_order' => 2, 'responder_self_checkin' => true, 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Tappen en serveren op het Willemsplein']); + $secKids = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Kinderactiviteiten', 'type' => 'standard', 'category' => 'Entertainment', 'icon' => 'tabler-balloon', 'sort_order' => 3, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Begeleid kinderactiviteiten en spelletjes']); + $secBev = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Beveiliging', 'type' => 'standard', 'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 4, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Beveiliging en crowd management']); $kSections = [$secPodium, $secBar, $secKids, $secBev]; $kLocations = [$locErasmus, $locWillems, $locOudeHaven, $locOudeHaven]; diff --git a/api/routes/api.php b/api/routes/api.php index 49bbbbc4..ba920e05 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -129,6 +129,8 @@ Route::middleware('auth:sanctum')->group(function () { Route::prefix('events/{event}')->group(function () { Route::apiResource('locations', LocationController::class) ->except(['show']); + Route::get('sections/registration-settings', [FestivalSectionController::class, 'registrationSettings']); + Route::put('sections/registration-settings', [FestivalSectionController::class, 'updateRegistrationSettings']); Route::apiResource('sections', FestivalSectionController::class) ->except(['show']); Route::post('sections/reorder', [FestivalSectionController::class, 'reorder']); diff --git a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php index 02fff666..0fcc5981 100644 --- a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php +++ b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php @@ -35,11 +35,20 @@ class PublicRegistrationDataTest extends TestCase $section = FestivalSection::factory()->create([ 'event_id' => $event->id, 'type' => 'standard', + 'show_in_registration' => true, + 'registration_description' => 'Test description', ]); FestivalSection::factory()->create([ 'event_id' => $event->id, 'type' => 'cross_event', + 'show_in_registration' => true, + ]); + + FestivalSection::factory()->create([ + 'event_id' => $event->id, + 'type' => 'standard', + 'show_in_registration' => false, ]); $timeSlot = TimeSlot::factory()->create([ @@ -82,4 +91,112 @@ class PublicRegistrationDataTest extends TestCase $response->assertNotFound(); } + + public function test_includes_registration_description_in_sections(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'desc-event', + ]); + + FestivalSection::factory()->create([ + 'event_id' => $event->id, + 'type' => 'standard', + 'show_in_registration' => true, + 'registration_description' => 'Tap bier en drankjes', + ]); + + $response = $this->getJson('/api/v1/public/events/desc-event/registration-data'); + + $response->assertOk() + ->assertJsonPath('data.sections.0.registration_description', 'Tap bier en drankjes'); + } + + public function test_excludes_sections_with_show_in_registration_false(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'filter-event', + ]); + + FestivalSection::factory()->create([ + 'event_id' => $event->id, + 'type' => 'standard', + 'show_in_registration' => false, + ]); + + $response = $this->getJson('/api/v1/public/events/filter-event/registration-data'); + + $response->assertOk() + ->assertJsonCount(0, 'data.sections'); + } + + public function test_festival_deduplicates_sections_by_name(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'dedup-festival', + ]); + + $sub1 = Event::factory()->subEvent($festival)->create(['status' => 'registration_open']); + $sub2 = Event::factory()->subEvent($festival)->create(['status' => 'registration_open']); + $sub3 = Event::factory()->subEvent($festival)->create(['status' => 'published']); + + // Same section name across 3 sub-events + foreach ([$sub1, $sub2, $sub3] as $sub) { + FestivalSection::factory()->create([ + 'event_id' => $sub->id, + 'name' => 'Hoofdpodium Bar', + 'type' => 'standard', + 'show_in_registration' => true, + 'category' => 'Bar', + ]); + } + + TimeSlot::factory()->create(['event_id' => $sub1->id, 'person_type' => 'VOLUNTEER']); + + $response = $this->getJson('/api/v1/public/events/dedup-festival/registration-data'); + + $response->assertOk() + ->assertJsonCount(1, 'data.sections') + ->assertJsonPath('data.sections.0.name', 'Hoofdpodium Bar'); + } + + public function test_festival_excludes_parent_operational_sections(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'parent-ops-festival', + ]); + + $sub = Event::factory()->subEvent($festival)->create(['status' => 'registration_open']); + + // Parent operational section (should be excluded) + FestivalSection::factory()->create([ + 'event_id' => $festival->id, + 'name' => 'Terreinploeg', + 'type' => 'standard', + 'show_in_registration' => true, + ]); + + // Sub-event section (should be included) + FestivalSection::factory()->create([ + 'event_id' => $sub->id, + 'name' => 'Bar', + 'type' => 'standard', + 'show_in_registration' => true, + ]); + + TimeSlot::factory()->create(['event_id' => $sub->id, 'person_type' => 'VOLUNTEER']); + + $response = $this->getJson('/api/v1/public/events/parent-ops-festival/registration-data'); + + $response->assertOk() + ->assertJsonCount(1, 'data.sections') + ->assertJsonPath('data.sections.0.name', 'Bar'); + } } diff --git a/api/tests/Feature/Api/V1/RegistrationSettingsTest.php b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php new file mode 100644 index 00000000..f74b904a --- /dev/null +++ b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php @@ -0,0 +1,233 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->orgAdmin->assignRole('org_admin'); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + } + + public function test_get_returns_grouped_unique_section_names(): void + { + $sub1 = Event::factory()->subEvent($this->festival)->create(); + $sub2 = Event::factory()->subEvent($this->festival)->create(); + + foreach ([$sub1, $sub2] as $sub) { + FestivalSection::factory()->create([ + 'event_id' => $sub->id, + 'name' => 'Hoofdpodium Bar', + 'category' => 'Bar', + 'icon' => 'tabler-beer', + 'show_in_registration' => true, + 'registration_description' => 'Tap bier', + ]); + } + + FestivalSection::factory()->create([ + 'event_id' => $sub1->id, + 'name' => 'Backstage', + 'category' => 'Hospitality', + 'show_in_registration' => false, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->festival->id}/sections/registration-settings"); + + $response->assertOk() + ->assertJsonCount(2, 'data'); + + $bar = collect($response->json('data'))->firstWhere('name', 'Hoofdpodium Bar'); + $this->assertEquals(2, $bar['section_count']); + $this->assertCount(2, $bar['section_ids']); + $this->assertTrue($bar['show_in_registration']); + $this->assertEquals('Tap bier', $bar['registration_description']); + } + + public function test_put_updates_all_instances_across_festival(): void + { + $sub1 = Event::factory()->subEvent($this->festival)->create(); + $sub2 = Event::factory()->subEvent($this->festival)->create(); + $sub3 = Event::factory()->subEvent($this->festival)->create(); + + $sections = []; + foreach ([$sub1, $sub2, $sub3] as $sub) { + $sections[] = FestivalSection::factory()->create([ + 'event_id' => $sub->id, + 'name' => 'Theatertent Bar', + 'show_in_registration' => false, + 'registration_description' => null, + ]); + } + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/events/{$this->festival->id}/sections/registration-settings", [ + 'name' => 'Theatertent Bar', + 'show_in_registration' => true, + 'registration_description' => 'Bediening in de overdekte theatertent', + ]); + + $response->assertOk(); + + foreach ($sections as $section) { + $this->assertDatabaseHas('festival_sections', [ + 'id' => $section->id, + 'show_in_registration' => true, + 'registration_description' => 'Bediening in de overdekte theatertent', + ]); + } + } + + public function test_put_creates_activity_log(): void + { + $sub = Event::factory()->subEvent($this->festival)->create(); + + FestivalSection::factory()->create([ + 'event_id' => $sub->id, + 'name' => 'EHBO', + 'show_in_registration' => false, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $this->putJson("/api/v1/events/{$this->festival->id}/sections/registration-settings", [ + 'name' => 'EHBO', + 'show_in_registration' => true, + 'registration_description' => null, + ]); + + $this->assertDatabaseHas('activity_log', [ + 'description' => 'section.registration_settings_updated', + ]); + } + + public function test_put_requires_authenticated_organizer(): void + { + $sub = Event::factory()->subEvent($this->festival)->create(); + + FestivalSection::factory()->create([ + 'event_id' => $sub->id, + 'name' => 'Bar', + 'show_in_registration' => false, + ]); + + // Unauthenticated + $response = $this->putJson("/api/v1/events/{$this->festival->id}/sections/registration-settings", [ + 'name' => 'Bar', + 'show_in_registration' => true, + 'registration_description' => null, + ]); + + $response->assertUnauthorized(); + } + + public function test_put_returns_404_for_nonexistent_section_name(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/events/{$this->festival->id}/sections/registration-settings", [ + 'name' => 'Nonexistent Section', + 'show_in_registration' => true, + 'registration_description' => null, + ]); + + $response->assertNotFound(); + } + + public function test_get_requires_authentication(): void + { + $response = $this->getJson("/api/v1/events/{$this->festival->id}/sections/registration-settings"); + + $response->assertUnauthorized(); + } + + public function test_flat_event_works_with_own_sections(): void + { + $flatEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'published', + ]); + + FestivalSection::factory()->create([ + 'event_id' => $flatEvent->id, + 'name' => 'Podium', + 'show_in_registration' => true, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$flatEvent->id}/sections/registration-settings"); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.name', 'Podium') + ->assertJsonPath('data.0.section_count', 1); + } + + public function test_section_preferences_stored_as_section_name(): void + { + // This is a regression check for the VolunteerRegistration flow + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + + \App\Models\CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + + FestivalSection::factory()->create([ + 'event_id' => $event->id, + 'name' => 'Backstage', + 'show_in_registration' => true, + ]); + + $response = $this->postJson("/api/v1/events/{$event->id}/volunteer-register", [ + 'name' => 'Test Vrijwilliger', + 'email' => 'test-section-pref@example.nl', + 'section_preferences' => [ + ['section_name' => 'Backstage', 'priority' => 1], + ], + ]); + + $response->assertStatus(201); + + $person = \App\Models\Person::where('email', 'test-section-pref@example.nl')->first(); + $prefs = $person->custom_fields['section_preferences']; + + $this->assertCount(1, $prefs); + $this->assertEquals('Backstage', $prefs[0]['section_name']); + $this->assertEquals(1, $prefs[0]['priority']); + } +} diff --git a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php new file mode 100644 index 00000000..944968bc --- /dev/null +++ b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php @@ -0,0 +1,375 @@ +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' => 'registration_open', + ]); + $this->section = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + ]); + $this->timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->event->id, + ]); + } + + // ─── Anonymous Registration ───────────────────────────────────────── + + public function test_volunteer_can_register_with_all_fields(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Jan de Vries', + 'email' => 'jan@voorbeeld.nl', + 'phone' => '+31612345678', + 'tshirt_size' => 'L', + 'motivation' => 'Ik wil graag helpen bij dit festival!', + 'availabilities' => [ + ['time_slot_id' => $this->timeSlot->id, 'preference_level' => 5], + ], + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('persons', [ + 'email' => 'jan@voorbeeld.nl', + 'event_id' => $this->event->id, + 'status' => PersonStatus::PENDING->value, + ]); + } + + public function test_volunteer_can_register_with_minimal_fields(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Sophie Bakker', + 'email' => 'sophie@voorbeeld.nl', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('persons', [ + 'email' => 'sophie@voorbeeld.nl', + 'name' => 'Sophie Bakker', + 'event_id' => $this->event->id, + ]); + } + + public function test_registration_resolves_to_parent_event(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + $subEvent = Event::factory()->subEvent($festival)->create([ + 'status' => 'registration_open', + ]); + TimeSlot::factory()->create(['event_id' => $festival->id]); + + $response = $this->postJson("/api/v1/events/{$subEvent->id}/volunteer-register", [ + 'name' => 'Pieter Jansen', + 'email' => 'pieter@voorbeeld.nl', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('persons', [ + 'email' => 'pieter@voorbeeld.nl', + 'event_id' => $festival->id, + ]); + } + + public function test_registration_syncs_availabilities(): void + { + $timeSlot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Fleur Vermeer', + 'email' => 'fleur@voorbeeld.nl', + 'availabilities' => [ + ['time_slot_id' => $this->timeSlot->id, 'preference_level' => 4], + ['time_slot_id' => $timeSlot2->id, 'preference_level' => 2], + ], + ]); + + $response->assertStatus(201); + + $person = Person::where('email', 'fleur@voorbeeld.nl')->first(); + + $this->assertDatabaseHas('volunteer_availabilities', [ + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'preference_level' => 4, + ]); + $this->assertDatabaseHas('volunteer_availabilities', [ + 'person_id' => $person->id, + 'time_slot_id' => $timeSlot2->id, + 'preference_level' => 2, + ]); + } + + public function test_registration_stores_custom_fields(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Daan Mulder', + 'email' => 'daan@voorbeeld.nl', + 'tshirt_size' => 'XL', + 'motivation' => 'Ik vind festivals geweldig.', + 'section_preferences' => [ + ['section_name' => $this->section->name, 'priority' => 1], + ], + ]); + + $response->assertStatus(201); + + $person = Person::where('email', 'daan@voorbeeld.nl')->first(); + $customFields = $person->custom_fields; + + $this->assertEquals('XL', $customFields['tshirt_size']); + $this->assertEquals('Ik vind festivals geweldig.', $customFields['motivation']); + $this->assertNotEmpty($customFields['section_preferences']); + } + + public function test_duplicate_email_rejected(): void + { + $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Anna Smit', + 'email' => 'anna@voorbeeld.nl', + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Anna Smit', + 'email' => 'anna@voorbeeld.nl', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } + + public function test_rejected_person_can_reregister(): void + { + Person::factory()->rejected()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->volunteerCrowdType->id, + 'email' => 'herkan@voorbeeld.nl', + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Herkan Poging', + 'email' => 'herkan@voorbeeld.nl', + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('persons', [ + 'email' => 'herkan@voorbeeld.nl', + 'status' => PersonStatus::PENDING->value, + ]); + } + + public function test_event_not_registration_open(): void + { + $draftEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + $response = $this->postJson("/api/v1/events/{$draftEvent->id}/volunteer-register", [ + 'name' => 'Test Persoon', + 'email' => 'test@voorbeeld.nl', + ]); + + $response->assertStatus(422); + } + + public function test_invalid_time_slot_rejected(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'name' => 'Bas van Dijk', + 'email' => 'bas@voorbeeld.nl', + 'availabilities' => [ + ['time_slot_id' => '01JNONEXISTENT00000000000', 'preference_level' => 3], + ], + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('availabilities.0.time_slot_id'); + } + + // ─── Authenticated Registration ───────────────────────────────────── + + public function test_authenticated_user_registration(): void + { + $user = User::factory()->create([ + 'name' => 'Lisa de Groot', + 'email' => 'lisa@voorbeeld.nl', + ]); + $this->organisation->users()->attach($user, ['role' => 'org_member']); + Sanctum::actingAs($user); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", []); + + $response->assertStatus(201); + + $this->assertDatabaseHas('persons', [ + 'email' => 'lisa@voorbeeld.nl', + 'user_id' => $user->id, + 'event_id' => $this->event->id, + ]); + } + + public function test_authenticated_ignores_request_email(): void + { + $user = User::factory()->create([ + 'name' => 'Mark Visser', + 'email' => 'mark@voorbeeld.nl', + ]); + $this->organisation->users()->attach($user, ['role' => 'org_member']); + Sanctum::actingAs($user); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'email' => 'nep@voorbeeld.nl', + ]); + + $response->assertStatus(201); + + $person = Person::where('user_id', $user->id)->first(); + $this->assertEquals('mark@voorbeeld.nl', $person->email); + } + + public function test_authenticated_duplicate_rejected(): void + { + $user = User::factory()->create([ + 'name' => 'Eva Hendriks', + 'email' => 'eva@voorbeeld.nl', + ]); + $this->organisation->users()->attach($user, ['role' => 'org_member']); + Sanctum::actingAs($user); + + $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", []); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } + + // ─── Portal Token Auth ────────────────────────────────────────────── + + public function test_missing_token_returns_error(): void + { + $response = $this->postJson('/api/v1/portal/token-auth', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('token'); + } + + public function test_invalid_token_returns_401(): void + { + // artists table exists via migration, so an invalid token returns 401 + $response = $this->postJson('/api/v1/portal/token-auth', [ + 'token' => 'some-random-invalid-token', + ]); + + $response->assertStatus(401); + $response->assertJson(['message' => 'Invalid or expired portal token']); + } + + public function test_token_auth_returns_501_when_no_tables(): void + { + // Drop the artists table to simulate no token tables existing + Schema::dropIfExists('artists'); + + $response = $this->postJson('/api/v1/portal/token-auth', [ + 'token' => '01JTEST000000000000000000', + ]); + + $response->assertStatus(501); + $response->assertJson(['message' => 'Token-based portal access is not yet available']); + } + + // ─── Portal Me ────────────────────────────────────────────────────── + + public function test_authenticated_user_gets_person(): void + { + $user = User::factory()->create(['name' => 'Karin Bos']); + $this->organisation->users()->attach($user, ['role' => 'org_member']); + + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->volunteerCrowdType->id, + 'user_id' => $user->id, + 'email' => $user->email, + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertStatus(200); + $response->assertJsonPath('data.email', $user->email); + } + + public function test_authenticated_user_no_person_returns_404(): void + { + $user = User::factory()->create(['name' => 'Tom Kuiper']); + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertStatus(404); + $response->assertJson(['message' => 'No registration found for this event']); + } + + public function test_missing_event_id_returns_422(): void + { + $user = User::factory()->create(['name' => 'Sanne Bruin']); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/portal/me'); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('event_id'); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertStatus(401); + } +} diff --git a/apps/app/src/components/sections/CreateSectionDialog.vue b/apps/app/src/components/sections/CreateSectionDialog.vue index 547ad8a0..3be44669 100644 --- a/apps/app/src/components/sections/CreateSectionDialog.vue +++ b/apps/app/src/components/sections/CreateSectionDialog.vue @@ -32,6 +32,8 @@ const form = ref({ type: 'standard' as SectionType, crew_auto_accepts: false, responder_self_checkin: true, + show_in_registration: false, + registration_description: null as string | null, }) const errors = ref>({}) @@ -56,6 +58,8 @@ function resetForm() { type: 'standard', crew_auto_accepts: false, responder_self_checkin: true, + show_in_registration: false, + registration_description: null, } errors.value = {} refVForm.value?.resetValidation() @@ -76,6 +80,8 @@ function onSubmit() { sort_order: props.nextSortOrder, crew_auto_accepts: form.value.crew_auto_accepts, responder_self_checkin: form.value.responder_self_checkin, + show_in_registration: form.value.show_in_registration, + registration_description: form.value.registration_description || null, }, { onSuccess: (result) => { @@ -188,6 +194,29 @@ function onSubmit() { persistent-hint /> + + + + + + diff --git a/apps/app/src/components/sections/EditSectionDialog.vue b/apps/app/src/components/sections/EditSectionDialog.vue index 0e90f766..64a70eb4 100644 --- a/apps/app/src/components/sections/EditSectionDialog.vue +++ b/apps/app/src/components/sections/EditSectionDialog.vue @@ -28,6 +28,8 @@ const form = ref({ type: 'standard' as SectionType, crew_auto_accepts: false, responder_self_checkin: true, + show_in_registration: false, + registration_description: null as string | null, }) const errors = ref>({}) @@ -52,6 +54,8 @@ watch( type: section.type, crew_auto_accepts: section.crew_auto_accepts, responder_self_checkin: section.responder_self_checkin, + show_in_registration: section.show_in_registration, + registration_description: section.registration_description, } } }, @@ -79,6 +83,8 @@ function onSubmit() { icon: form.value.icon || null, crew_auto_accepts: form.value.crew_auto_accepts, responder_self_checkin: form.value.responder_self_checkin, + show_in_registration: form.value.show_in_registration, + registration_description: form.value.registration_description || null, }, { onSuccess: () => { @@ -180,6 +186,29 @@ function onSubmit() { persistent-hint /> + + + + + + diff --git a/apps/app/src/types/section.ts b/apps/app/src/types/section.ts index f449eefe..a5a3be67 100644 --- a/apps/app/src/types/section.ts +++ b/apps/app/src/types/section.ts @@ -18,6 +18,8 @@ export interface FestivalSection { crew_need: number | null crew_auto_accepts: boolean responder_self_checkin: boolean + show_in_registration: boolean + registration_description: string | null created_at: string } @@ -39,6 +41,8 @@ export interface Shift { status: ShiftStatus filled_slots: number fill_rate: number + is_overbooked: boolean + overbooking_count: number effective_start_time: string effective_end_time: string time_slot: TimeSlot | null @@ -61,6 +65,8 @@ export interface CreateSectionPayload { sort_order?: number crew_auto_accepts?: boolean responder_self_checkin?: boolean + show_in_registration?: boolean + registration_description?: string | null } export interface UpdateSectionPayload extends Partial {} diff --git a/apps/portal/src/pages/register/[eventSlug].vue b/apps/portal/src/pages/register/[eventSlug].vue index 2bb16832..2184fd5b 100644 --- a/apps/portal/src/pages/register/[eventSlug].vue +++ b/apps/portal/src/pages/register/[eventSlug].vue @@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/useAuthStore' import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration' import { fullRegistrationSchema } from '@/schemas/registrationSchema' import type { + SectionOption, SectionPreference, TimeSlotOption, VolunteerAvailability, @@ -68,8 +69,8 @@ watch(() => authStore.user, (user) => { } }, { immediate: true }) -// Step 4: Section preferences -const selectedSectionIds = ref([]) +// Step 4: Section preferences (by name, not ID) +const selectedSections = ref([]) // Step 5: Availability const selectedTimeSlotIds = ref([]) @@ -91,6 +92,27 @@ const motivationItems = [ const stepTitles = ['Over jou', 'Meer over jou', 'Motivatie', 'Secties', 'Beschikbaarheid'] +// Section helpers +const sectionsByCategory = computed(() => { + if (!registrationData.value?.sections) return {} + return registrationData.value.sections.reduce((groups, section) => { + const cat = section.category || 'Overig' + if (!groups[cat]) groups[cat] = [] + groups[cat].push(section) + return groups + }, {} as Record) +}) + +function isSelected(name: string) { + return selectedSections.value.includes(name) +} + +function getSelectionPriority(name: string) { + return selectedSections.value.indexOf(name) + 1 +} + +const selectedCount = computed(() => selectedSections.value.length) + // Computed const timeSlotsByDate = computed(() => { if (!registrationData.value?.time_slots) return [] @@ -140,13 +162,13 @@ function prevStep() { } // Section toggle -function toggleSection(sectionId: string) { - const idx = selectedSectionIds.value.indexOf(sectionId) - if (idx >= 0) { - selectedSectionIds.value.splice(idx, 1) +function toggleSection(name: string) { + const idx = selectedSections.value.indexOf(name) + if (idx !== -1) { + selectedSections.value.splice(idx, 1) } - else if (selectedSectionIds.value.length < 5) { - selectedSectionIds.value.push(sectionId) + else if (selectedSections.value.length < 5) { + selectedSections.value.push(name) } } @@ -194,8 +216,8 @@ async function onSubmit() { if (!registrationData.value) return - const sectionPreferences: SectionPreference[] = selectedSectionIds.value.map((id, index) => ({ - section_id: id, + const sectionPreferences: SectionPreference[] = selectedSections.value.map((name, index) => ({ + section_name: name, priority: index + 1, })) @@ -490,69 +512,87 @@ async function onSubmit() {
-

- Er zijn geen secties beschikbaar voor dit evenement. +

+ Bij welke onderdelen wil je het liefst helpen? +

+

+ Selecteer maximaal 5 onderdelen. Je eerste keuze heeft de hoogste prioriteit.

-