feat: registration section preferences with show_in_registration filtering and deduplication
Add show_in_registration and registration_description columns to festival_sections. Registration form now shows deduplicated sections by name (across sub-events), filtered by show_in_registration=true, grouped by category with card-based UI. Section preferences use section_name instead of section_id. Add GET/PUT registration-settings endpoints for festival-level bulk management. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\Api\V1\ReorderFestivalSectionsRequest;
|
use App\Http\Requests\Api\V1\ReorderFestivalSectionsRequest;
|
||||||
use App\Http\Requests\Api\V1\StoreFestivalSectionRequest;
|
use App\Http\Requests\Api\V1\StoreFestivalSectionRequest;
|
||||||
use App\Http\Requests\Api\V1\UpdateFestivalSectionRequest;
|
use App\Http\Requests\Api\V1\UpdateFestivalSectionRequest;
|
||||||
|
use App\Http\Requests\Api\V1\UpdateRegistrationSettingsRequest;
|
||||||
use App\Http\Resources\Api\V1\FestivalSectionResource;
|
use App\Http\Resources\Api\V1\FestivalSectionResource;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\FestivalSection;
|
use App\Models\FestivalSection;
|
||||||
@@ -94,6 +95,82 @@ final class FestivalSectionController extends Controller
|
|||||||
return response()->json(null, 204);
|
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
|
public function reorder(ReorderFestivalSectionsRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('reorder', [FestivalSection::class, $event]);
|
Gate::authorize('reorder', [FestivalSection::class, $event]);
|
||||||
|
|||||||
@@ -24,26 +24,30 @@ final class PublicRegistrationDataController extends Controller
|
|||||||
|
|
||||||
$festivalEvent = $event->isSubEvent() ? $event->parent : $event;
|
$festivalEvent = $event->isSubEvent() ? $event->parent : $event;
|
||||||
|
|
||||||
$sectionQuery = FestivalSection::where('event_id', $festivalEvent->id)
|
if ($festivalEvent->isFestival() || $festivalEvent->hasChildren()) {
|
||||||
->where(function ($query) {
|
// Festival: get child event sections only (skip parent operational sections)
|
||||||
$query->where('type', '!=', 'cross_event')
|
$childIds = Event::where('parent_event_id', $festivalEvent->id)->pluck('id');
|
||||||
->orWhereNull('type');
|
|
||||||
})
|
|
||||||
->ordered();
|
|
||||||
|
|
||||||
if ($festivalEvent->isFestival()) {
|
$sections = FestivalSection::whereIn('event_id', $childIds)
|
||||||
$childIds = $festivalEvent->children()->pluck('id');
|
->where('show_in_registration', true)
|
||||||
$sectionQuery->orWhere(function ($query) use ($childIds) {
|
->where('type', 'standard')
|
||||||
$query->whereIn('event_id', $childIds)
|
->select('id', 'name', 'category', 'icon', 'registration_description')
|
||||||
->where(function ($q) {
|
->orderBy('category')
|
||||||
$q->where('type', '!=', 'cross_event')
|
->orderBy('sort_order')
|
||||||
->orWhereNull('type');
|
->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()
|
$timeSlots = $festivalEvent->getAllRelevantTimeSlots()
|
||||||
->where('person_type', 'VOLUNTEER')
|
->where('person_type', 'VOLUNTEER')
|
||||||
->values();
|
->values();
|
||||||
@@ -62,6 +66,7 @@ final class PublicRegistrationDataController extends Controller
|
|||||||
'name' => $section->name,
|
'name' => $section->name,
|
||||||
'category' => $section->category,
|
'category' => $section->category,
|
||||||
'icon' => $section->icon,
|
'icon' => $section->icon,
|
||||||
|
'registration_description' => $section->registration_description,
|
||||||
]),
|
]),
|
||||||
'time_slots' => $timeSlots->map(fn (TimeSlot $slot) => [
|
'time_slots' => $timeSlots->map(fn (TimeSlot $slot) => [
|
||||||
'id' => $slot->id,
|
'id' => $slot->id,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ final class StoreFestivalSectionRequest extends FormRequest
|
|||||||
'crew_accreditation_level' => ['nullable', 'string', 'max:50'],
|
'crew_accreditation_level' => ['nullable', 'string', 'max:50'],
|
||||||
'public_form_accreditation_level' => ['nullable', 'string', 'max:50'],
|
'public_form_accreditation_level' => ['nullable', 'string', 'max:50'],
|
||||||
'timed_accreditations' => ['nullable', 'boolean'],
|
'timed_accreditations' => ['nullable', 'boolean'],
|
||||||
|
'show_in_registration' => ['nullable', 'boolean'],
|
||||||
|
'registration_description' => ['nullable', 'string', 'max:500'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ final class UpdateFestivalSectionRequest extends FormRequest
|
|||||||
'timed_accreditations' => ['sometimes', 'boolean'],
|
'timed_accreditations' => ['sometimes', 'boolean'],
|
||||||
'crew_accreditation_level' => ['nullable', 'string', 'max:50'],
|
'crew_accreditation_level' => ['nullable', 'string', 'max:50'],
|
||||||
'public_form_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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class UpdateRegistrationSettingsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'show_in_registration' => ['required', 'boolean'],
|
||||||
|
'registration_description' => ['nullable', 'string', 'max:500'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class VolunteerRegistrationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$user = auth('sanctum')->user();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$this->merge([
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'_authenticated' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ final class FestivalSectionResource extends JsonResource
|
|||||||
'crew_accreditation_level' => $this->crew_accreditation_level,
|
'crew_accreditation_level' => $this->crew_accreditation_level,
|
||||||
'public_form_accreditation_level' => $this->public_form_accreditation_level,
|
'public_form_accreditation_level' => $this->public_form_accreditation_level,
|
||||||
'timed_accreditations' => $this->timed_accreditations,
|
'timed_accreditations' => $this->timed_accreditations,
|
||||||
|
'show_in_registration' => $this->show_in_registration,
|
||||||
|
'registration_description' => $this->registration_description,
|
||||||
'created_at' => $this->created_at->toIso8601String(),
|
'created_at' => $this->created_at->toIso8601String(),
|
||||||
'shifts_count' => $this->whenCounted('shifts'),
|
'shifts_count' => $this->whenCounted('shifts'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ final class FestivalSection extends Model
|
|||||||
'crew_accreditation_level',
|
'crew_accreditation_level',
|
||||||
'public_form_accreditation_level',
|
'public_form_accreditation_level',
|
||||||
'timed_accreditations',
|
'timed_accreditations',
|
||||||
|
'show_in_registration',
|
||||||
|
'registration_description',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -43,6 +45,7 @@ final class FestivalSection extends Model
|
|||||||
'added_to_timeline' => 'boolean',
|
'added_to_timeline' => 'boolean',
|
||||||
'responder_self_checkin' => 'boolean',
|
'responder_self_checkin' => 'boolean',
|
||||||
'timed_accreditations' => 'boolean',
|
'timed_accreditations' => 'boolean',
|
||||||
|
'show_in_registration' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
193
api/app/Services/VolunteerRegistrationService.php
Normal file
193
api/app/Services/VolunteerRegistrationService.php
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\PersonStatus;
|
||||||
|
use App\Models\CrowdType;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Person;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\VolunteerAvailability;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class VolunteerRegistrationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PersonIdentityService $identityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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<int, array<string, mixed>> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('festival_sections', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -247,34 +247,38 @@ class DevSeeder extends Seeder
|
|||||||
'event_id' => $festival->id, 'name' => 'EHBO', 'type' => 'cross_event',
|
'event_id' => $festival->id, 'name' => 'EHBO', 'type' => 'cross_event',
|
||||||
'category' => 'Veiligheid', 'icon' => 'tabler-first-aid-kit', 'sort_order' => 1,
|
'category' => 'Veiligheid', 'icon' => 'tabler-first-aid-kit', 'sort_order' => 1,
|
||||||
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
|
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
|
||||||
|
'show_in_registration' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$nachtsecurity = FestivalSection::create([
|
$nachtsecurity = FestivalSection::create([
|
||||||
'event_id' => $festival->id, 'name' => 'Nachtsecurity', 'type' => 'standard',
|
'event_id' => $festival->id, 'name' => 'Nachtsecurity', 'type' => 'standard',
|
||||||
'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 2,
|
'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 2,
|
||||||
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
|
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
|
||||||
|
'show_in_registration' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$terreinploeg = FestivalSection::create([
|
$terreinploeg = FestivalSection::create([
|
||||||
'event_id' => $festival->id, 'name' => 'Terreinploeg', 'type' => 'standard',
|
'event_id' => $festival->id, 'name' => 'Terreinploeg', 'type' => 'standard',
|
||||||
'category' => 'Productie', 'icon' => 'tabler-shovel', 'sort_order' => 3,
|
'category' => 'Productie', 'icon' => 'tabler-shovel', 'sort_order' => 3,
|
||||||
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
|
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
|
||||||
|
'show_in_registration' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$accreditatiebalie = FestivalSection::create([
|
$accreditatiebalie = FestivalSection::create([
|
||||||
'event_id' => $festival->id, 'name' => 'Accreditatiebalie', 'type' => 'cross_event',
|
'event_id' => $festival->id, 'name' => 'Accreditatiebalie', 'type' => 'cross_event',
|
||||||
'category' => 'Ontvangst', 'icon' => 'tabler-id-badge', 'sort_order' => 4,
|
'category' => 'Ontvangst', 'icon' => 'tabler-id-badge', 'sort_order' => 4,
|
||||||
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
|
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
|
||||||
|
'show_in_registration' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Sub-event sections (5 per sub-event) ──
|
// ── Sub-event sections (5 per sub-event) ──
|
||||||
|
|
||||||
$sectionDefs = [
|
$sectionDefs = [
|
||||||
'hoofdbar' => ['name' => 'Hoofdpodium Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', '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],
|
'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],
|
'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],
|
'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],
|
'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 = [];
|
$sections = [];
|
||||||
@@ -290,6 +294,8 @@ class DevSeeder extends Seeder
|
|||||||
'sort_order' => $order++,
|
'sort_order' => $order++,
|
||||||
'responder_self_checkin' => true,
|
'responder_self_checkin' => true,
|
||||||
'crew_auto_accepts' => $def['crew_auto_accepts'],
|
'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;
|
$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) ──
|
// ── 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)
|
$approvedPersons = Person::where('event_id', $festival->id)
|
||||||
->where('status', 'approved')
|
->where('status', 'approved')
|
||||||
->get();
|
->get();
|
||||||
@@ -631,6 +696,11 @@ class DevSeeder extends Seeder
|
|||||||
$statusIdx++;
|
$statusIdx++;
|
||||||
$isApproved = in_array($status, [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED]);
|
$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([
|
ShiftAssignment::create([
|
||||||
'shift_id' => $shift->id,
|
'shift_id' => $shift->id,
|
||||||
'person_id' => $person->id,
|
'person_id' => $person->id,
|
||||||
@@ -641,6 +711,11 @@ class DevSeeder extends Seeder
|
|||||||
'approved_at' => $isApproved ? now() : null,
|
'approved_at' => $isApproved ? now() : null,
|
||||||
'rejection_reason' => $status === ShiftAssignmentStatus::REJECTED ? 'Geen beschikbare plek' : 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;
|
$existing[] = $shift->time_slot_id;
|
||||||
}
|
}
|
||||||
$usedPersonSlots[$person->id] = $existing;
|
$usedPersonSlots[$person->id] = $existing;
|
||||||
@@ -840,9 +915,9 @@ class DevSeeder extends Seeder
|
|||||||
// ── Sections, time slots, shifts per sub-event ──
|
// ── Sections, time slots, shifts per sub-event ──
|
||||||
|
|
||||||
$sectionDefs = [
|
$sectionDefs = [
|
||||||
['name' => 'Schaatsbaan Bar', 'category' => 'Bar', 'icon' => 'tabler-beer'],
|
['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'],
|
['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'],
|
['name' => 'Terrein', 'category' => 'Productie', 'icon' => 'tabler-shovel', 'show_in_registration' => true, 'registration_description' => 'IJsbaan onderhoud en terreinbeheer'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$allShifts = [];
|
$allShifts = [];
|
||||||
@@ -863,6 +938,8 @@ class DevSeeder extends Seeder
|
|||||||
'sort_order' => $order + 1,
|
'sort_order' => $order + 1,
|
||||||
'responder_self_checkin' => true,
|
'responder_self_checkin' => true,
|
||||||
'crew_auto_accepts' => 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');
|
$draftShifts = collect($allShifts)->filter(fn (Shift $shift) => $shift->status === 'draft');
|
||||||
$allApproved = $approvedVol->merge($approvedCrew);
|
$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) {
|
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) {
|
foreach ($assigned as $person) {
|
||||||
ShiftAssignment::create([
|
ShiftAssignment::create([
|
||||||
'shift_id' => $shift->id,
|
'shift_id' => $shift->id,
|
||||||
@@ -981,10 +1059,10 @@ class DevSeeder extends Seeder
|
|||||||
|
|
||||||
// ── Sections (4) ──
|
// ── 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]);
|
$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]);
|
$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]);
|
$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]);
|
$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];
|
$kSections = [$secPodium, $secBar, $secKids, $secBev];
|
||||||
$kLocations = [$locErasmus, $locWillems, $locOudeHaven, $locOudeHaven];
|
$kLocations = [$locErasmus, $locWillems, $locOudeHaven, $locOudeHaven];
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::prefix('events/{event}')->group(function () {
|
Route::prefix('events/{event}')->group(function () {
|
||||||
Route::apiResource('locations', LocationController::class)
|
Route::apiResource('locations', LocationController::class)
|
||||||
->except(['show']);
|
->except(['show']);
|
||||||
|
Route::get('sections/registration-settings', [FestivalSectionController::class, 'registrationSettings']);
|
||||||
|
Route::put('sections/registration-settings', [FestivalSectionController::class, 'updateRegistrationSettings']);
|
||||||
Route::apiResource('sections', FestivalSectionController::class)
|
Route::apiResource('sections', FestivalSectionController::class)
|
||||||
->except(['show']);
|
->except(['show']);
|
||||||
Route::post('sections/reorder', [FestivalSectionController::class, 'reorder']);
|
Route::post('sections/reorder', [FestivalSectionController::class, 'reorder']);
|
||||||
|
|||||||
@@ -35,11 +35,20 @@ class PublicRegistrationDataTest extends TestCase
|
|||||||
$section = FestivalSection::factory()->create([
|
$section = FestivalSection::factory()->create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'type' => 'standard',
|
'type' => 'standard',
|
||||||
|
'show_in_registration' => true,
|
||||||
|
'registration_description' => 'Test description',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
FestivalSection::factory()->create([
|
FestivalSection::factory()->create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'type' => 'cross_event',
|
'type' => 'cross_event',
|
||||||
|
'show_in_registration' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
FestivalSection::factory()->create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'type' => 'standard',
|
||||||
|
'show_in_registration' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$timeSlot = TimeSlot::factory()->create([
|
$timeSlot = TimeSlot::factory()->create([
|
||||||
@@ -82,4 +91,112 @@ class PublicRegistrationDataTest extends TestCase
|
|||||||
|
|
||||||
$response->assertNotFound();
|
$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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
233
api/tests/Feature/Api/V1/RegistrationSettingsTest.php
Normal file
233
api/tests/Feature/Api/V1/RegistrationSettingsTest.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\FestivalSection;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RegistrationSettingsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Organisation $organisation;
|
||||||
|
private User $orgAdmin;
|
||||||
|
private Event $festival;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
375
api/tests/Feature/Api/V1/VolunteerRegistrationTest.php
Normal file
375
api/tests/Feature/Api/V1/VolunteerRegistrationTest.php
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\PersonStatus;
|
||||||
|
use App\Models\CrowdType;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\FestivalSection;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Person;
|
||||||
|
use App\Models\TimeSlot;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class VolunteerRegistrationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Organisation $organisation;
|
||||||
|
private Event $event;
|
||||||
|
private CrowdType $volunteerCrowdType;
|
||||||
|
private TimeSlot $timeSlot;
|
||||||
|
private FestivalSection $section;
|
||||||
|
|
||||||
|
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' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ const form = ref({
|
|||||||
type: 'standard' as SectionType,
|
type: 'standard' as SectionType,
|
||||||
crew_auto_accepts: false,
|
crew_auto_accepts: false,
|
||||||
responder_self_checkin: true,
|
responder_self_checkin: true,
|
||||||
|
show_in_registration: false,
|
||||||
|
registration_description: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = ref<Record<string, string>>({})
|
const errors = ref<Record<string, string>>({})
|
||||||
@@ -56,6 +58,8 @@ function resetForm() {
|
|||||||
type: 'standard',
|
type: 'standard',
|
||||||
crew_auto_accepts: false,
|
crew_auto_accepts: false,
|
||||||
responder_self_checkin: true,
|
responder_self_checkin: true,
|
||||||
|
show_in_registration: false,
|
||||||
|
registration_description: null,
|
||||||
}
|
}
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
refVForm.value?.resetValidation()
|
refVForm.value?.resetValidation()
|
||||||
@@ -76,6 +80,8 @@ function onSubmit() {
|
|||||||
sort_order: props.nextSortOrder,
|
sort_order: props.nextSortOrder,
|
||||||
crew_auto_accepts: form.value.crew_auto_accepts,
|
crew_auto_accepts: form.value.crew_auto_accepts,
|
||||||
responder_self_checkin: form.value.responder_self_checkin,
|
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) => {
|
onSuccess: (result) => {
|
||||||
@@ -188,6 +194,29 @@ function onSubmit() {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VSwitch
|
||||||
|
v-model="form.show_in_registration"
|
||||||
|
label="Toon in vrijwilligersregistratie"
|
||||||
|
hint="Dit werkgebied verschijnt in het aanmeldformulier voor vrijwilligers"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
v-if="form.show_in_registration"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<VTextarea
|
||||||
|
v-model="form.registration_description"
|
||||||
|
label="Beschrijving voor vrijwilligers"
|
||||||
|
:counter="500"
|
||||||
|
rows="2"
|
||||||
|
auto-grow
|
||||||
|
hint="Korte uitleg zodat vrijwilligers weten wat dit werkgebied inhoudt"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="errors.registration_description"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const form = ref({
|
|||||||
type: 'standard' as SectionType,
|
type: 'standard' as SectionType,
|
||||||
crew_auto_accepts: false,
|
crew_auto_accepts: false,
|
||||||
responder_self_checkin: true,
|
responder_self_checkin: true,
|
||||||
|
show_in_registration: false,
|
||||||
|
registration_description: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = ref<Record<string, string>>({})
|
const errors = ref<Record<string, string>>({})
|
||||||
@@ -52,6 +54,8 @@ watch(
|
|||||||
type: section.type,
|
type: section.type,
|
||||||
crew_auto_accepts: section.crew_auto_accepts,
|
crew_auto_accepts: section.crew_auto_accepts,
|
||||||
responder_self_checkin: section.responder_self_checkin,
|
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,
|
icon: form.value.icon || null,
|
||||||
crew_auto_accepts: form.value.crew_auto_accepts,
|
crew_auto_accepts: form.value.crew_auto_accepts,
|
||||||
responder_self_checkin: form.value.responder_self_checkin,
|
responder_self_checkin: form.value.responder_self_checkin,
|
||||||
|
show_in_registration: form.value.show_in_registration,
|
||||||
|
registration_description: form.value.registration_description || null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -180,6 +186,29 @@ function onSubmit() {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VSwitch
|
||||||
|
v-model="form.show_in_registration"
|
||||||
|
label="Toon in vrijwilligersregistratie"
|
||||||
|
hint="Dit werkgebied verschijnt in het aanmeldformulier voor vrijwilligers"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
v-if="form.show_in_registration"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<VTextarea
|
||||||
|
v-model="form.registration_description"
|
||||||
|
label="Beschrijving voor vrijwilligers"
|
||||||
|
:counter="500"
|
||||||
|
rows="2"
|
||||||
|
auto-grow
|
||||||
|
hint="Korte uitleg zodat vrijwilligers weten wat dit werkgebied inhoudt"
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="errors.registration_description"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface FestivalSection {
|
|||||||
crew_need: number | null
|
crew_need: number | null
|
||||||
crew_auto_accepts: boolean
|
crew_auto_accepts: boolean
|
||||||
responder_self_checkin: boolean
|
responder_self_checkin: boolean
|
||||||
|
show_in_registration: boolean
|
||||||
|
registration_description: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ export interface Shift {
|
|||||||
status: ShiftStatus
|
status: ShiftStatus
|
||||||
filled_slots: number
|
filled_slots: number
|
||||||
fill_rate: number
|
fill_rate: number
|
||||||
|
is_overbooked: boolean
|
||||||
|
overbooking_count: number
|
||||||
effective_start_time: string
|
effective_start_time: string
|
||||||
effective_end_time: string
|
effective_end_time: string
|
||||||
time_slot: TimeSlot | null
|
time_slot: TimeSlot | null
|
||||||
@@ -61,6 +65,8 @@ export interface CreateSectionPayload {
|
|||||||
sort_order?: number
|
sort_order?: number
|
||||||
crew_auto_accepts?: boolean
|
crew_auto_accepts?: boolean
|
||||||
responder_self_checkin?: boolean
|
responder_self_checkin?: boolean
|
||||||
|
show_in_registration?: boolean
|
||||||
|
registration_description?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSectionPayload extends Partial<CreateSectionPayload> {}
|
export interface UpdateSectionPayload extends Partial<CreateSectionPayload> {}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
|||||||
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
||||||
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
||||||
import type {
|
import type {
|
||||||
|
SectionOption,
|
||||||
SectionPreference,
|
SectionPreference,
|
||||||
TimeSlotOption,
|
TimeSlotOption,
|
||||||
VolunteerAvailability,
|
VolunteerAvailability,
|
||||||
@@ -68,8 +69,8 @@ watch(() => authStore.user, (user) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Step 4: Section preferences
|
// Step 4: Section preferences (by name, not ID)
|
||||||
const selectedSectionIds = ref<string[]>([])
|
const selectedSections = ref<string[]>([])
|
||||||
|
|
||||||
// Step 5: Availability
|
// Step 5: Availability
|
||||||
const selectedTimeSlotIds = ref<string[]>([])
|
const selectedTimeSlotIds = ref<string[]>([])
|
||||||
@@ -91,6 +92,27 @@ const motivationItems = [
|
|||||||
|
|
||||||
const stepTitles = ['Over jou', 'Meer over jou', 'Motivatie', 'Secties', 'Beschikbaarheid']
|
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<string, SectionOption[]>)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
// Computed
|
||||||
const timeSlotsByDate = computed(() => {
|
const timeSlotsByDate = computed(() => {
|
||||||
if (!registrationData.value?.time_slots) return []
|
if (!registrationData.value?.time_slots) return []
|
||||||
@@ -140,13 +162,13 @@ function prevStep() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Section toggle
|
// Section toggle
|
||||||
function toggleSection(sectionId: string) {
|
function toggleSection(name: string) {
|
||||||
const idx = selectedSectionIds.value.indexOf(sectionId)
|
const idx = selectedSections.value.indexOf(name)
|
||||||
if (idx >= 0) {
|
if (idx !== -1) {
|
||||||
selectedSectionIds.value.splice(idx, 1)
|
selectedSections.value.splice(idx, 1)
|
||||||
}
|
}
|
||||||
else if (selectedSectionIds.value.length < 5) {
|
else if (selectedSections.value.length < 5) {
|
||||||
selectedSectionIds.value.push(sectionId)
|
selectedSections.value.push(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +216,8 @@ async function onSubmit() {
|
|||||||
|
|
||||||
if (!registrationData.value) return
|
if (!registrationData.value) return
|
||||||
|
|
||||||
const sectionPreferences: SectionPreference[] = selectedSectionIds.value.map((id, index) => ({
|
const sectionPreferences: SectionPreference[] = selectedSections.value.map((name, index) => ({
|
||||||
section_id: id,
|
section_name: name,
|
||||||
priority: index + 1,
|
priority: index + 1,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -490,69 +512,87 @@ async function onSubmit() {
|
|||||||
<!-- Step 4: Voorkeurssecties -->
|
<!-- Step 4: Voorkeurssecties -->
|
||||||
<VWindowItem :value="4">
|
<VWindowItem :value="4">
|
||||||
<div class="pa-4 pa-sm-6">
|
<div class="pa-4 pa-sm-6">
|
||||||
<p
|
<h3 class="text-h6 mb-1">
|
||||||
v-if="registrationData.sections.length === 0"
|
Bij welke onderdelen wil je het liefst helpen?
|
||||||
class="text-body-1 text-medium-emphasis"
|
</h3>
|
||||||
>
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
Er zijn geen secties beschikbaar voor dit evenement.
|
Selecteer maximaal 5 onderdelen. Je eerste keuze heeft de hoogste prioriteit.
|
||||||
</p>
|
</p>
|
||||||
<template v-else>
|
|
||||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
|
||||||
Selecteer maximaal 5 secties waar je graag wilt werken.
|
|
||||||
De volgorde van selectie bepaalt je voorkeur.
|
|
||||||
</p>
|
|
||||||
<VList lines="two">
|
|
||||||
<VListItem
|
|
||||||
v-for="section in registrationData.sections"
|
|
||||||
:key="section.id"
|
|
||||||
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
|
|
||||||
class="section-item"
|
|
||||||
@click="toggleSection(section.id)"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<VCheckboxBtn
|
|
||||||
:model-value="selectedSectionIds.includes(section.id)"
|
|
||||||
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
|
|
||||||
@click.stop="toggleSection(section.id)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<VListItemTitle class="d-flex align-center ga-2">
|
|
||||||
<VIcon
|
|
||||||
v-if="section.icon"
|
|
||||||
:icon="section.icon"
|
|
||||||
size="18"
|
|
||||||
/>
|
|
||||||
{{ section.name }}
|
|
||||||
</VListItemTitle>
|
|
||||||
<VListItemSubtitle v-if="section.category">
|
|
||||||
<VChip
|
|
||||||
size="x-small"
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
{{ section.category }}
|
|
||||||
</VChip>
|
|
||||||
</VListItemSubtitle>
|
|
||||||
<template
|
|
||||||
v-if="selectedSectionIds.includes(section.id)"
|
|
||||||
#append
|
|
||||||
>
|
|
||||||
<VChip
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Voorkeur {{ selectedSectionIds.indexOf(section.id) + 1 }}
|
|
||||||
</VChip>
|
|
||||||
</template>
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
|
|
||||||
<p
|
<template
|
||||||
v-if="selectedSectionIds.length > 0"
|
v-for="(sections, category) in sectionsByCategory"
|
||||||
class="text-body-2 text-medium-emphasis mt-4"
|
:key="category"
|
||||||
>
|
>
|
||||||
{{ selectedSectionIds.length }} van 5 secties geselecteerd
|
<div class="text-subtitle-2 text-medium-emphasis mt-4 mb-2">
|
||||||
</p>
|
{{ category }}
|
||||||
|
</div>
|
||||||
|
<VRow dense>
|
||||||
|
<VCol
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.id"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
>
|
||||||
|
<VCard
|
||||||
|
:variant="isSelected(section.name) ? 'flat' : 'outlined'"
|
||||||
|
:color="isSelected(section.name) ? 'primary' : undefined"
|
||||||
|
class="cursor-pointer"
|
||||||
|
:disabled="!isSelected(section.name) && selectedCount >= 5"
|
||||||
|
@click="toggleSection(section.name)"
|
||||||
|
>
|
||||||
|
<VCardText class="d-flex align-center ga-3 pa-3">
|
||||||
|
<VCheckboxBtn
|
||||||
|
:model-value="isSelected(section.name)"
|
||||||
|
readonly
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
<VIcon
|
||||||
|
v-if="section.icon"
|
||||||
|
size="20"
|
||||||
|
>
|
||||||
|
{{ section.icon }}
|
||||||
|
</VIcon>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="text-body-1 font-weight-medium">
|
||||||
|
{{ section.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="section.registration_description"
|
||||||
|
class="text-body-2 text-medium-emphasis"
|
||||||
|
>
|
||||||
|
{{ section.registration_description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VChip
|
||||||
|
v-if="isSelected(section.name)"
|
||||||
|
size="x-small"
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
>
|
||||||
|
#{{ getSelectionPriority(section.name) }}
|
||||||
|
</VChip>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="!Object.keys(sectionsByCategory).length"
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
Er zijn nog geen werkgebieden geconfigureerd voor dit evenement.
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="selectedCount > 0"
|
||||||
|
class="text-body-2 text-medium-emphasis mt-4"
|
||||||
|
>
|
||||||
|
{{ selectedCount }} van 5 onderdelen geselecteerd
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface SectionOption {
|
|||||||
name: string
|
name: string
|
||||||
category: string | null
|
category: string | null
|
||||||
icon: string | null
|
icon: string | null
|
||||||
|
registration_description: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSlotOption {
|
export interface TimeSlotOption {
|
||||||
@@ -27,7 +28,7 @@ export interface TimeSlotOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SectionPreference {
|
export interface SectionPreference {
|
||||||
section_id: string
|
section_name: string
|
||||||
priority: number
|
priority: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,43 @@ Returns 422 with `errors`, `current_status`, `requested_status`, and `allowed_tr
|
|||||||
> Shifts on cross_event sections must use the **parent festival's event_id** in API calls,
|
> Shifts on cross_event sections must use the **parent festival's event_id** in API calls,
|
||||||
> since the section's `event_id` points to the parent.
|
> since the section's `event_id` points to the parent.
|
||||||
|
|
||||||
|
### Registration Settings (Festival-level bulk management)
|
||||||
|
|
||||||
|
- `GET /events/{event}/sections/registration-settings` — returns unique section names across the festival with registration visibility, description, and counts
|
||||||
|
- `PUT /events/{event}/sections/registration-settings` — bulk update registration visibility for a section name across all instances in the festival
|
||||||
|
|
||||||
|
#### GET Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"name": "Hoofdpodium Bar",
|
||||||
|
"category": "Bar",
|
||||||
|
"icon": "tabler-beer",
|
||||||
|
"show_in_registration": true,
|
||||||
|
"registration_description": "Tap bier en drankjes voor festivalgangers",
|
||||||
|
"section_count": 3,
|
||||||
|
"section_ids": ["ulid1", "ulid2", "ulid3"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PUT Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Hoofdpodium Bar",
|
||||||
|
"show_in_registration": true,
|
||||||
|
"registration_description": "Tap bier en drankjes voor festivalgangers"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the full updated registration-settings response. Creates activity log `section.registration_settings_updated`.
|
||||||
|
|
||||||
|
Auth: org_admin or event_manager on the event's organisation.
|
||||||
|
|
||||||
## Time Slots
|
## Time Slots
|
||||||
|
|
||||||
- `GET /events/{event}/time-slots`
|
- `GET /events/{event}/time-slots`
|
||||||
@@ -391,7 +428,7 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al
|
|||||||
|
|
||||||
## Public Registration Data
|
## Public Registration Data
|
||||||
|
|
||||||
- `GET /public/events/{slug}/registration-data` — public, no auth. Returns event info, available sections, and volunteer time slots for the registration form. Only returns events with status `registration_open`. Excludes `cross_event` sections. Only includes time slots with `person_type = VOLUNTEER`. Resolves sub-events to parent festival.
|
- `GET /public/events/{slug}/registration-data` — public, no auth. Returns event info, available sections, and volunteer time slots for the registration form. Only returns events with status `registration_open`. Only includes sections with `show_in_registration = true` and `type = standard`. For festivals: returns child event sections only (deduplicated by name), excluding parent operational sections. Only includes time slots with `person_type = VOLUNTEER`. Resolves sub-events to parent festival.
|
||||||
|
|
||||||
### Response
|
### Response
|
||||||
|
|
||||||
@@ -399,7 +436,7 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al
|
|||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
"event": { "id": "01JXYZ...", "name": "Echt Feesten 2026", "start_date": "2026-07-10", "end_date": "2026-07-12", "organisation_id": "01JXYZ..." },
|
"event": { "id": "01JXYZ...", "name": "Echt Feesten 2026", "start_date": "2026-07-10", "end_date": "2026-07-12", "organisation_id": "01JXYZ..." },
|
||||||
"sections": [{ "id": "01JXYZ...", "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-glass" }],
|
"sections": [{ "id": "01JXYZ...", "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-glass", "registration_description": "Tap bier en drankjes voor festivalgangers" }],
|
||||||
"time_slots": [{ "id": "01JXYZ...", "name": "Vrijdag Avond", "date": "2026-07-10", "start_time": "18:00:00", "end_time": "02:00:00", "duration_hours": 8 }]
|
"time_slots": [{ "id": "01JXYZ...", "name": "Vrijdag Avond", "date": "2026-07-10", "start_time": "18:00:00", "end_time": "02:00:00", "duration_hours": 8 }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
|||||||
| `crew_accreditation_level` | string nullable | **v1.5** Default accreditation level for crew (e.g. AAA, AA, A) |
|
| `crew_accreditation_level` | string nullable | **v1.5** Default accreditation level for crew (e.g. AAA, AA, A) |
|
||||||
| `public_form_accreditation_level` | string nullable | **v1.5** Accreditation level for public form registrants |
|
| `public_form_accreditation_level` | string nullable | **v1.5** Accreditation level for public form registrants |
|
||||||
| `timed_accreditations` | bool | **v1.5** Accreditations are time-limited for this section |
|
| `timed_accreditations` | bool | **v1.5** Accreditations are time-limited for this section |
|
||||||
|
| `show_in_registration` | bool | **v1.8** Show this section in the volunteer registration form |
|
||||||
|
| `registration_description` | text nullable | **v1.8** Description shown to volunteers in the registration form |
|
||||||
| `deleted_at` | timestamp nullable | Soft delete |
|
| `deleted_at` | timestamp nullable | Soft delete |
|
||||||
|
|
||||||
**Relations:** `hasMany` shifts
|
**Relations:** `hasMany` shifts
|
||||||
@@ -309,6 +311,7 @@ scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
|||||||
- `added_to_timeline`: false
|
- `added_to_timeline`: false
|
||||||
- `responder_self_checkin`: true
|
- `responder_self_checkin`: true
|
||||||
- `timed_accreditations`: false
|
- `timed_accreditations`: false
|
||||||
|
- `show_in_registration`: false
|
||||||
|
|
||||||
> **Note:** "Overkoepelende" sections (shared across all sub-events of a festival)
|
> **Note:** "Overkoepelende" sections (shared across all sub-events of a festival)
|
||||||
> are identified by `type = 'cross_event'`. There is no separate `is_shared` boolean
|
> are identified by `type = 'cross_event'`. There is no separate `is_shared` boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user