diff --git a/api/app/Http/Controllers/Api/V1/PersonController.php b/api/app/Http/Controllers/Api/V1/PersonController.php index 97a63e8a..d74f8e35 100644 --- a/api/app/Http/Controllers/Api/V1/PersonController.php +++ b/api/app/Http/Controllers/Api/V1/PersonController.php @@ -19,7 +19,6 @@ use App\Models\Person; use App\Models\User; use App\Services\EmailService; use App\Services\PersonIdentityService; -use App\Services\TagSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; @@ -34,7 +33,6 @@ final class PersonController extends Controller public function __construct( private readonly PersonIdentityService $identityService, - private readonly TagSyncService $tagSyncService, private readonly EmailService $emailService, ) {} @@ -188,8 +186,6 @@ final class PersonController extends Controller $person->save(); } - $this->tagSyncService->syncFromRegistration($person); - if ($person->email) { $portalUrl = config('app.frontend_portal_url'); diff --git a/api/app/Http/Controllers/Api/V1/PersonFieldValueController.php b/api/app/Http/Controllers/Api/V1/PersonFieldValueController.php deleted file mode 100644 index 7f340036..00000000 --- a/api/app/Http/Controllers/Api/V1/PersonFieldValueController.php +++ /dev/null @@ -1,48 +0,0 @@ -verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('view', [$person, $event]); - - $values = $this->service->getPersonValues($person); - - return PersonFieldValueResource::collection($values); - } - - public function upsert(UpsertPersonFieldValuesRequest $request, Organisation $organisation, Event $event, Person $person): JsonResponse - { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('update', [$person, $event]); - - $this->service->upsertPersonValues($person, $request->validated()['values']); - - $values = $this->service->getPersonValues($person); - - return $this->success(PersonFieldValueResource::collection($values)); - } -} diff --git a/api/app/Http/Controllers/Api/V1/PersonSectionPreferenceController.php b/api/app/Http/Controllers/Api/V1/PersonSectionPreferenceController.php deleted file mode 100644 index ac317142..00000000 --- a/api/app/Http/Controllers/Api/V1/PersonSectionPreferenceController.php +++ /dev/null @@ -1,48 +0,0 @@ -verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('view', [$person, $event]); - - $preferences = $this->service->getPreferences($person); - - return PersonSectionPreferenceResource::collection($preferences); - } - - public function replace(ReplacePersonSectionPreferencesRequest $request, Organisation $organisation, Event $event, Person $person): JsonResponse - { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('update', [$person, $event]); - - $this->service->replacePreferences($person, $request->validated()['preferences']); - - $preferences = $this->service->getPreferences($person); - - return $this->success(PersonSectionPreferenceResource::collection($preferences)); - } -} diff --git a/api/app/Http/Controllers/Api/V1/PortalMeController.php b/api/app/Http/Controllers/Api/V1/PortalMeController.php index c139e6cb..dd85cf3a 100644 --- a/api/app/Http/Controllers/Api/V1/PortalMeController.php +++ b/api/app/Http/Controllers/Api/V1/PortalMeController.php @@ -37,7 +37,6 @@ final class PortalMeController extends Controller 'shiftAssignments.shift.festivalSection', 'shiftAssignments.shift.timeSlot', 'volunteerAvailabilities.timeSlot', - 'fieldValues.registrationFormField', 'sectionPreferences.festivalSection', ]) ->first(); diff --git a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php deleted file mode 100644 index b9c39218..00000000 --- a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php +++ /dev/null @@ -1,129 +0,0 @@ -where('status', 'registration_open') - ->first(); - - if ($event === null) { - abort(404, 'Event not found or not accepting registrations.'); - } - - $festivalEvent = $event->isSubEvent() ? $event->parent : $event; - - 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'); - - $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(); - } - - $timeSlots = $festivalEvent->getAllRelevantTimeSlots() - ->where('person_type', 'VOLUNTEER') - ->values(); - - $registrationFields = RegistrationFormField::where('event_id', $festivalEvent->id) - ->portalVisible() - ->ordered() - ->get(); - - $organisationId = $festivalEvent->organisation_id; - - return response()->json([ - 'data' => [ - 'event' => [ - 'id' => $festivalEvent->id, - 'name' => $festivalEvent->name, - 'start_date' => $festivalEvent->start_date->toDateString(), - 'end_date' => $festivalEvent->end_date->toDateString(), - 'organisation_id' => $organisationId, - 'registration_banner_url' => $festivalEvent->registration_banner_url, - 'registration_welcome_text' => $festivalEvent->registration_welcome_text, - 'registration_logo_url' => $festivalEvent->registration_logo_url, - 'registration_show_section_preferences' => (bool) $festivalEvent->registration_show_section_preferences, - 'registration_show_availability' => (bool) $festivalEvent->registration_show_availability, - ], - 'sections' => $sections->map(fn (FestivalSection $section) => [ - 'id' => $section->id, - 'name' => $section->name, - 'category' => $section->category, - 'icon' => $section->icon, - 'registration_description' => $section->registration_description, - ]), - 'time_slots' => $timeSlots->map(fn (TimeSlot $slot) => [ - 'id' => $slot->id, - 'name' => $slot->name, - 'date' => $slot->date->toDateString(), - 'start_time' => $slot->start_time, - 'end_time' => $slot->end_time, - 'duration_hours' => $slot->duration_hours, - ]), - 'registration_fields' => $registrationFields->map(function (RegistrationFormField $field) use ($organisationId) { - $data = [ - 'id' => $field->id, - 'label' => $field->label, - 'slug' => $field->slug, - 'field_type' => $field->field_type->value, - 'options' => $field->options, - 'normalized_options' => $field->normalized_options, - 'tag_categories' => $field->tag_categories, - 'is_required' => $field->is_required, - 'help_text' => $field->help_text, - 'display_width' => $field->display_width->value, - ]; - - if ($field->field_type === \App\Enums\RegistrationFieldType::TAG_PICKER) { - $query = PersonTag::where('organisation_id', $organisationId) - ->where('is_active', true); - - if (!empty($field->tag_categories)) { - $query->whereIn('category', $field->tag_categories); - } - - $data['available_tags'] = $query->orderBy('category')->orderBy('sort_order') - ->get() - ->map(fn (PersonTag $tag) => [ - 'id' => $tag->id, - 'name' => $tag->name, - 'category' => $tag->category, - ]); - } - - return $data; - }), - ], - ]); - } -} diff --git a/api/app/Http/Controllers/Api/V1/RegistrationFieldTemplateController.php b/api/app/Http/Controllers/Api/V1/RegistrationFieldTemplateController.php deleted file mode 100644 index 258561b3..00000000 --- a/api/app/Http/Controllers/Api/V1/RegistrationFieldTemplateController.php +++ /dev/null @@ -1,62 +0,0 @@ -service->listForOrganisation($organisation); - - return RegistrationFieldTemplateResource::collection($templates); - } - - public function store(StoreRegistrationFieldTemplateRequest $request, Organisation $organisation): JsonResponse - { - Gate::authorize('create', [RegistrationFieldTemplate::class, $organisation]); - - $template = $this->service->createTemplate($organisation, $request->validated()); - - return $this->created(new RegistrationFieldTemplateResource($template)); - } - - public function update( - UpdateRegistrationFieldTemplateRequest $request, - Organisation $organisation, - RegistrationFieldTemplate $registrationFieldTemplate, - ): JsonResponse { - Gate::authorize('update', [$registrationFieldTemplate, $organisation]); - - $template = $this->service->updateTemplate($registrationFieldTemplate, $request->validated()); - - return $this->success(new RegistrationFieldTemplateResource($template)); - } - - public function destroy(Organisation $organisation, RegistrationFieldTemplate $registrationFieldTemplate): JsonResponse - { - Gate::authorize('delete', [$registrationFieldTemplate, $organisation]); - - $this->service->deleteTemplate($registrationFieldTemplate); - - return response()->json(null, 204); - } -} diff --git a/api/app/Http/Controllers/Api/V1/RegistrationFormFieldController.php b/api/app/Http/Controllers/Api/V1/RegistrationFormFieldController.php deleted file mode 100644 index 1cb9514c..00000000 --- a/api/app/Http/Controllers/Api/V1/RegistrationFormFieldController.php +++ /dev/null @@ -1,119 +0,0 @@ -verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('viewAny', [RegistrationFormField::class, $event]); - - $fields = $this->service->listForEvent($event); - - return RegistrationFormFieldResource::collection($fields); - } - - public function store(StoreRegistrationFormFieldRequest $request, Organisation $organisation, Event $event): JsonResponse - { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('create', [RegistrationFormField::class, $event]); - - $field = $this->service->createField($event, $request->validated()); - - return $this->created(new RegistrationFormFieldResource($field)); - } - - public function update( - UpdateRegistrationFormFieldRequest $request, - Organisation $organisation, - Event $event, - RegistrationFormField $registrationField, - ): JsonResponse { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('update', [$registrationField, $event]); - - $field = $this->service->updateField($registrationField, $request->validated()); - - return $this->success(new RegistrationFormFieldResource($field)); - } - - public function destroy(Organisation $organisation, Event $event, RegistrationFormField $registrationField): JsonResponse - { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('delete', [$registrationField, $event]); - - $this->service->deleteField($registrationField); - - return response()->json(null, 204); - } - - public function reorder(ReorderRegistrationFormFieldsRequest $request, Organisation $organisation, Event $event): JsonResponse - { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('reorder', [RegistrationFormField::class, $event]); - - $this->service->reorderFields($event, $request->validated()['ids']); - - return response()->json(null, 204); - } - - public function fromTemplate(Request $request, Organisation $organisation, Event $event): JsonResponse - { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('create', [RegistrationFormField::class, $event]); - - $request->validate([ - 'template_id' => ['required', 'ulid', 'exists:registration_field_templates,id'], - ]); - - $template = RegistrationFieldTemplate::findOrFail($request->input('template_id')); - - if ($template->organisation_id !== $event->organisation_id) { - return $this->error('Template does not belong to this organisation.', 422); - } - - $field = $this->templateService->createFieldFromTemplate($event, $template); - - return $this->created(new RegistrationFormFieldResource($field)); - } - - public function importFromEvent(ImportFromEventRequest $request, Organisation $organisation, Event $event): JsonResponse - { - $this->verifyEventBelongsToOrganisation($organisation, $event); - Gate::authorize('create', [RegistrationFormField::class, $event]); - - $sourceEvent = Event::findOrFail($request->validated()['source_event_id']); - - $fields = $this->service->importFromEvent($event, $sourceEvent); - - return $this->success(RegistrationFormFieldResource::collection($fields)); - } -} diff --git a/api/app/Http/Controllers/Api/V1/VolunteerRegistrationController.php b/api/app/Http/Controllers/Api/V1/VolunteerRegistrationController.php deleted file mode 100644 index a6244f7e..00000000 --- a/api/app/Http/Controllers/Api/V1/VolunteerRegistrationController.php +++ /dev/null @@ -1,42 +0,0 @@ -user(); - - $person = $this->registrationService->register( - $event, - $request->validated(), - $user - ); - - $person->load('crowdType'); - - $responseData = [ - 'person' => new PersonResource($person), - ]; - - if ($person->wasRecentlyCreated) { - return $this->created($responseData); - } - - return $this->success($responseData); - } -} diff --git a/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php b/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php deleted file mode 100644 index 9fa63778..00000000 --- a/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php +++ /dev/null @@ -1,27 +0,0 @@ - */ - public function rules(): array - { - $event = $this->route('event'); - - return [ - 'ids' => ['required', 'array', 'min:1'], - 'ids.*' => ['required', 'ulid', Rule::exists('registration_form_fields', 'id')->where('event_id', $event->id)], - ]; - } -} diff --git a/api/app/Http/Requests/Api/V1/ReplacePersonSectionPreferencesRequest.php b/api/app/Http/Requests/Api/V1/ReplacePersonSectionPreferencesRequest.php deleted file mode 100644 index 21854fec..00000000 --- a/api/app/Http/Requests/Api/V1/ReplacePersonSectionPreferencesRequest.php +++ /dev/null @@ -1,72 +0,0 @@ - */ - public function rules(): array - { - return [ - 'preferences' => ['required', 'array', 'min:1', 'max:5'], - 'preferences.*.festival_section_id' => ['required', 'ulid'], - 'preferences.*.priority' => ['required', 'integer', 'min:1', 'max:5'], - ]; - } - - public function withValidator($validator): void - { - $validator->after(function ($validator) { - $preferences = $this->input('preferences', []); - - if (!is_array($preferences)) { - return; - } - - // Priorities must be unique - $priorities = array_column($preferences, 'priority'); - if (count($priorities) !== count(array_unique($priorities))) { - $validator->errors()->add('preferences', 'Priorities must be unique within the request.'); - } - - // Validate section IDs belong to the event - $event = $this->route('event'); - $person = $this->route('person'); - - if (!$event || !$person) { - return; - } - - // Valid sections: own event's sections + parent festival's cross_event sections - $validSectionIds = FestivalSection::where('event_id', $event->id) - ->pluck('id'); - - if ($event->parent_event_id) { - $parentCrossEventSections = FestivalSection::where('event_id', $event->parent_event_id) - ->where('type', 'cross_event') - ->pluck('id'); - $validSectionIds = $validSectionIds->merge($parentCrossEventSections); - } - - foreach ($preferences as $index => $pref) { - $sectionId = $pref['festival_section_id'] ?? null; - if ($sectionId && !$validSectionIds->contains($sectionId)) { - $validator->errors()->add( - "preferences.{$index}.festival_section_id", - 'Section does not belong to this event.' - ); - } - } - }); - } -} diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php deleted file mode 100644 index 7fe1d870..00000000 --- a/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php +++ /dev/null @@ -1,57 +0,0 @@ - */ - public function rules(): array - { - $fieldType = $this->input('field_type'); - $type = RegistrationFieldType::tryFrom($fieldType); - - $rules = [ - 'label' => ['required', 'string', 'max:255'], - 'field_type' => ['required', Rule::in(array_column(RegistrationFieldType::cases(), 'value'))], - 'help_text' => ['nullable', 'string', 'max:5000'], - 'sort_order' => ['nullable', 'integer', 'min:0'], - 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], - ]; - - if ($type?->isStructural()) { - return $rules; - } - - return array_merge($rules, [ - 'options' => [ - $type?->requiresOptions() ? 'required' : 'nullable', - $type?->prohibitsOptions() ? 'prohibited' : 'nullable', - 'array', - ], - 'options.*' => ['required'], - 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], - 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_categories' => [ - $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', - 'array', - ], - 'tag_categories.*' => ['string', 'max:50'], - 'is_required' => ['nullable', 'boolean'], - 'is_filterable' => ['nullable', 'boolean'], - 'is_portal_visible' => ['nullable', 'boolean'], - 'is_admin_only' => ['nullable', 'boolean'], - ]); - } -} diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php deleted file mode 100644 index 9b85fa1d..00000000 --- a/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php +++ /dev/null @@ -1,57 +0,0 @@ - */ - public function rules(): array - { - $fieldType = $this->input('field_type'); - $type = RegistrationFieldType::tryFrom($fieldType); - - $rules = [ - 'label' => ['required', 'string', 'max:255'], - 'field_type' => ['required', Rule::in(array_column(RegistrationFieldType::cases(), 'value'))], - 'help_text' => ['nullable', 'string', 'max:5000'], - 'sort_order' => ['nullable', 'integer', 'min:0'], - 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], - ]; - - if ($type?->isStructural()) { - return $rules; - } - - return array_merge($rules, [ - 'options' => [ - $type?->requiresOptions() ? 'required' : 'nullable', - $type?->prohibitsOptions() ? 'prohibited' : 'nullable', - 'array', - ], - 'options.*' => ['required'], - 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], - 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_categories' => [ - $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', - 'array', - ], - 'tag_categories.*' => ['string', 'max:50'], - 'is_required' => ['nullable', 'boolean'], - 'is_portal_visible' => ['nullable', 'boolean'], - 'is_admin_only' => ['nullable', 'boolean'], - 'is_filterable' => ['nullable', 'boolean'], - ]); - } -} diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php deleted file mode 100644 index 50d60701..00000000 --- a/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php +++ /dev/null @@ -1,39 +0,0 @@ - */ - public function rules(): array - { - return [ - 'label' => ['sometimes', 'string', 'max:255'], - 'options' => ['nullable', 'array'], - 'options.*' => ['required'], - 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], - 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_categories' => ['nullable', 'array'], - 'tag_categories.*' => ['string', 'max:50'], - 'is_required' => ['nullable', 'boolean'], - 'is_filterable' => ['nullable', 'boolean'], - 'is_portal_visible' => ['nullable', 'boolean'], - 'is_admin_only' => ['nullable', 'boolean'], - 'help_text' => ['nullable', 'string', 'max:5000'], - 'sort_order' => ['nullable', 'integer', 'min:0'], - 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], - ]; - } -} diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php deleted file mode 100644 index 9cda10e6..00000000 --- a/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php +++ /dev/null @@ -1,38 +0,0 @@ - */ - public function rules(): array - { - return [ - 'label' => ['sometimes', 'string', 'max:255'], - 'options' => ['nullable', 'array'], - 'options.*' => ['required'], - 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], - 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_categories' => ['nullable', 'array'], - 'tag_categories.*' => ['string', 'max:50'], - 'is_required' => ['nullable', 'boolean'], - 'is_portal_visible' => ['nullable', 'boolean'], - 'is_admin_only' => ['nullable', 'boolean'], - 'is_filterable' => ['nullable', 'boolean'], - 'help_text' => ['nullable', 'string', 'max:5000'], - 'sort_order' => ['nullable', 'integer', 'min:0'], - 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], - ]; - } -} diff --git a/api/app/Http/Requests/Api/V1/UpsertPersonFieldValuesRequest.php b/api/app/Http/Requests/Api/V1/UpsertPersonFieldValuesRequest.php deleted file mode 100644 index 49f50e31..00000000 --- a/api/app/Http/Requests/Api/V1/UpsertPersonFieldValuesRequest.php +++ /dev/null @@ -1,133 +0,0 @@ - */ - public function rules(): array - { - return [ - 'values' => ['required', 'array'], - ]; - } - - public function withValidator($validator): void - { - $validator->after(function ($validator) { - $values = $this->input('values', []); - $event = $this->route('event'); - - if (!$event || !is_array($values)) { - return; - } - - $fields = RegistrationFormField::where('event_id', $event->id) - ->get() - ->keyBy('slug'); - - $orgId = $event->organisation_id; - - foreach ($values as $slug => $value) { - $field = $fields->get($slug); - - if ($field === null) { - $validator->errors()->add("values.{$slug}", "Unknown field: {$slug}"); - continue; - } - - if ($field->is_required && ($value === null || $value === '' || $value === [])) { - $validator->errors()->add("values.{$slug}", "The {$slug} field is required."); - continue; - } - - if ($value === null || $value === '') { - continue; - } - - match ($field->field_type) { - RegistrationFieldType::TEXT, RegistrationFieldType::TEXTAREA => $this->validateString($validator, $slug, $value), - RegistrationFieldType::NUMBER => $this->validateNumber($validator, $slug, $value), - RegistrationFieldType::BOOLEAN => $this->validateBoolean($validator, $slug, $value), - RegistrationFieldType::SELECT, RegistrationFieldType::RADIO => $this->validateSingleOption($validator, $slug, $value, $field), - RegistrationFieldType::MULTISELECT, RegistrationFieldType::CHECKBOX => $this->validateMultiOption($validator, $slug, $value, $field), - RegistrationFieldType::TAG_PICKER => $this->validateTagPicker($validator, $slug, $value, $orgId), - }; - } - }); - } - - private function validateString($validator, string $slug, mixed $value): void - { - if (!is_string($value) || mb_strlen($value) > 5000) { - $validator->errors()->add("values.{$slug}", "Must be a string (max 5000 characters)."); - } - } - - private function validateNumber($validator, string $slug, mixed $value): void - { - if (!is_numeric($value)) { - $validator->errors()->add("values.{$slug}", "Must be a number."); - } - } - - private function validateBoolean($validator, string $slug, mixed $value): void - { - if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) { - $validator->errors()->add("values.{$slug}", "Must be a boolean."); - } - } - - private function validateSingleOption($validator, string $slug, mixed $value, RegistrationFormField $field): void - { - if (!is_string($value) || !in_array($value, $field->options ?? [], true)) { - $validator->errors()->add("values.{$slug}", "Must be one of the defined options."); - } - } - - private function validateMultiOption($validator, string $slug, mixed $value, RegistrationFormField $field): void - { - if (!is_array($value)) { - $validator->errors()->add("values.{$slug}", "Must be an array."); - return; - } - - $options = $field->options ?? []; - foreach ($value as $item) { - if (!in_array($item, $options, true)) { - $validator->errors()->add("values.{$slug}", "Invalid option: {$item}"); - } - } - } - - private function validateTagPicker($validator, string $slug, mixed $value, string $orgId): void - { - if (!is_array($value)) { - $validator->errors()->add("values.{$slug}", "Must be an array of tag IDs."); - return; - } - - $validTagIds = PersonTag::where('organisation_id', $orgId) - ->where('is_active', true) - ->pluck('id') - ->all(); - - foreach ($value as $tagId) { - if (!in_array($tagId, $validTagIds, true)) { - $validator->errors()->add("values.{$slug}", "Invalid tag ID: {$tagId}"); - } - } - } -} diff --git a/api/app/Http/Resources/Api/V1/PersonFieldValueResource.php b/api/app/Http/Resources/Api/V1/PersonFieldValueResource.php deleted file mode 100644 index f225dce7..00000000 --- a/api/app/Http/Resources/Api/V1/PersonFieldValueResource.php +++ /dev/null @@ -1,34 +0,0 @@ -registrationFormField; - - return [ - 'field_slug' => $field?->slug, - 'field_label' => $field?->label, - 'field_type' => $field?->field_type?->value, - 'value' => $this->value, - 'selected_options' => $this->selected_options, - 'tag_names' => $this->when( - $field?->field_type === RegistrationFieldType::TAG_PICKER && !empty($this->selected_options), - function () { - return PersonTag::whereIn('id', $this->selected_options ?? []) - ->pluck('name') - ->all(); - } - ), - ]; - } -} diff --git a/api/app/Http/Resources/Api/V1/PersonResource.php b/api/app/Http/Resources/Api/V1/PersonResource.php index 0f29891e..00dbc479 100644 --- a/api/app/Http/Resources/Api/V1/PersonResource.php +++ b/api/app/Http/Resources/Api/V1/PersonResource.php @@ -64,8 +64,6 @@ final class PersonResource extends JsonResource 'added_by_user_id' => $this->pivot->added_by_user_id, ] ), - 'field_values' => PersonFieldValueResource::collection($this->whenLoaded('fieldValues')), - 'section_preferences' => PersonSectionPreferenceResource::collection($this->whenLoaded('sectionPreferences')), 'tags' => $this->when( $this->user_id && $this->relationLoaded('user'), function () { diff --git a/api/app/Http/Resources/Api/V1/PersonSectionPreferenceResource.php b/api/app/Http/Resources/Api/V1/PersonSectionPreferenceResource.php deleted file mode 100644 index aee58ef3..00000000 --- a/api/app/Http/Resources/Api/V1/PersonSectionPreferenceResource.php +++ /dev/null @@ -1,33 +0,0 @@ -whenLoaded('festivalSection'); - - return [ - 'festival_section_id' => $this->festival_section_id, - 'priority' => $this->priority, - 'section_name' => $this->when( - $this->relationLoaded('festivalSection'), - fn () => $this->festivalSection?->name - ), - 'section_icon' => $this->when( - $this->relationLoaded('festivalSection'), - fn () => $this->festivalSection?->icon - ), - 'section_category' => $this->when( - $this->relationLoaded('festivalSection'), - fn () => $this->festivalSection?->category - ), - ]; - } -} diff --git a/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php b/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php deleted file mode 100644 index 0e69612b..00000000 --- a/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php +++ /dev/null @@ -1,36 +0,0 @@ - $this->id, - 'organisation_id' => $this->organisation_id, - 'label' => $this->label, - 'slug' => $this->slug, - 'field_type' => $this->field_type->value, - 'options' => $this->options, - 'normalized_options' => $this->normalized_options, - 'tag_categories' => $this->tag_categories, - 'is_required' => $this->is_required, - 'is_filterable' => $this->is_filterable, - 'is_portal_visible' => $this->is_portal_visible, - 'is_admin_only' => $this->is_admin_only, - 'help_text' => $this->help_text, - 'sort_order' => $this->sort_order, - 'display_width' => $this->display_width->value, - 'is_system' => $this->is_system, - 'is_active' => $this->is_active, - 'created_at' => $this->created_at->toIso8601String(), - 'updated_at' => $this->updated_at->toIso8601String(), - ]; - } -} diff --git a/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php b/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php deleted file mode 100644 index 67c852b2..00000000 --- a/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php +++ /dev/null @@ -1,49 +0,0 @@ - $this->id, - 'event_id' => $this->event_id, - 'label' => $this->label, - 'slug' => $this->slug, - 'field_type' => $this->field_type->value, - 'options' => $this->options, - 'normalized_options' => $this->normalized_options, - 'tag_categories' => $this->tag_categories, - 'is_required' => $this->is_required, - 'is_portal_visible' => $this->is_portal_visible, - 'is_admin_only' => $this->is_admin_only, - 'is_filterable' => $this->is_filterable, - 'help_text' => $this->help_text, - 'sort_order' => $this->sort_order, - 'display_width' => $this->display_width->value, - 'created_at' => $this->created_at->toIso8601String(), - 'updated_at' => $this->updated_at->toIso8601String(), - 'available_tags' => $this->when( - $this->field_type === RegistrationFieldType::TAG_PICKER, - function () { - $query = PersonTag::where('organisation_id', $this->event->organisation_id) - ->where('is_active', true); - - if (!empty($this->tag_categories)) { - $query->whereIn('category', $this->tag_categories); - } - - return PersonTagResource::collection($query->orderBy('category')->orderBy('sort_order')->get()); - } - ), - ]; - } -} diff --git a/api/app/Models/Organisation.php b/api/app/Models/Organisation.php index 79f0dd6b..1ac50b0c 100644 --- a/api/app/Models/Organisation.php +++ b/api/app/Models/Organisation.php @@ -85,11 +85,6 @@ final class Organisation extends Model return $this->hasMany(PersonTag::class); } - public function registrationFieldTemplates(): HasMany - { - return $this->hasMany(RegistrationFieldTemplate::class); - } - public function emailSettings(): HasOne { return $this->hasOne(OrganisationEmailSettings::class); diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index 0e3041c5..af77b6f1 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -105,11 +105,6 @@ final class Person extends Model return $this->hasMany(VolunteerAvailability::class); } - public function fieldValues(): HasMany - { - return $this->hasMany(PersonFieldValue::class); - } - public function formSubmissions(): MorphMany { return $this->morphMany(\App\Models\FormBuilder\FormSubmission::class, 'subject'); diff --git a/api/app/Models/PersonFieldValue.php b/api/app/Models/PersonFieldValue.php deleted file mode 100644 index 8edca6ac..00000000 --- a/api/app/Models/PersonFieldValue.php +++ /dev/null @@ -1,37 +0,0 @@ - 'array', - ]; - } - - public function person(): BelongsTo - { - return $this->belongsTo(Person::class); - } - - public function registrationFormField(): BelongsTo - { - return $this->belongsTo(RegistrationFormField::class); - } -} diff --git a/api/app/Models/RegistrationFieldTemplate.php b/api/app/Models/RegistrationFieldTemplate.php deleted file mode 100644 index 490dd13b..00000000 --- a/api/app/Models/RegistrationFieldTemplate.php +++ /dev/null @@ -1,99 +0,0 @@ - RegistrationFieldType::class, - 'options' => 'array', - 'tag_categories' => 'array', - 'is_required' => 'boolean', - 'is_filterable' => 'boolean', - 'is_portal_visible' => 'boolean', - 'is_admin_only' => 'boolean', - 'sort_order' => 'integer', - 'display_width' => FieldDisplayWidth::class, - 'is_system' => 'boolean', - 'is_active' => 'boolean', - ]; - } - - /** @return array|null */ - public function getNormalizedOptionsAttribute(): ?array - { - if ($this->options === null) { - return null; - } - - return collect($this->options)->map(function (mixed $option): array { - if (is_string($option)) { - return ['label' => $option, 'description' => null]; - } - - return [ - 'label' => $option['label'] ?? (string) $option, - 'description' => $option['description'] ?? null, - ]; - })->toArray(); - } - - public function organisation(): BelongsTo - { - return $this->belongsTo(Organisation::class); - } - - public function scopeActive(Builder $query): Builder - { - return $query->where('is_active', true); - } - - public function scopeSystem(Builder $query): Builder - { - return $query->where('is_system', true); - } - - public function scopeOrdered(Builder $query): Builder - { - return $query->orderBy('sort_order'); - } -} diff --git a/api/app/Models/RegistrationFormField.php b/api/app/Models/RegistrationFormField.php deleted file mode 100644 index 32b87e83..00000000 --- a/api/app/Models/RegistrationFormField.php +++ /dev/null @@ -1,104 +0,0 @@ - RegistrationFieldType::class, - 'options' => 'array', - 'tag_categories' => 'array', - 'is_required' => 'boolean', - 'is_portal_visible' => 'boolean', - 'is_admin_only' => 'boolean', - 'is_filterable' => 'boolean', - 'sort_order' => 'integer', - 'display_width' => FieldDisplayWidth::class, - ]; - } - - public function event(): BelongsTo - { - return $this->belongsTo(Event::class); - } - - public function personFieldValues(): HasMany - { - return $this->hasMany(PersonFieldValue::class, 'registration_form_field_id'); - } - - /** @return array|null */ - public function getNormalizedOptionsAttribute(): ?array - { - if ($this->options === null) { - return null; - } - - return collect($this->options)->map(function (mixed $option): array { - if (is_string($option)) { - return ['label' => $option, 'description' => null]; - } - - return [ - 'label' => $option['label'] ?? (string) $option, - 'description' => $option['description'] ?? null, - ]; - })->toArray(); - } - - public function isMultiValue(): bool - { - return $this->field_type->isMultiValue(); - } - - public function scopeOrdered(Builder $query): Builder - { - return $query->orderBy('sort_order'); - } - - public function scopePortalVisible(Builder $query): Builder - { - return $query->where('is_portal_visible', true)->where('is_admin_only', false); - } -} diff --git a/api/app/Policies/RegistrationFieldTemplatePolicy.php b/api/app/Policies/RegistrationFieldTemplatePolicy.php deleted file mode 100644 index 2aadb4cf..00000000 --- a/api/app/Policies/RegistrationFieldTemplatePolicy.php +++ /dev/null @@ -1,53 +0,0 @@ -hasRole('super_admin') - || $organisation->users()->where('user_id', $user->id)->exists(); - } - - public function create(User $user, Organisation $organisation): bool - { - return $this->canManageOrganisation($user, $organisation); - } - - public function update(User $user, RegistrationFieldTemplate $template, Organisation $organisation): bool - { - if ($template->organisation_id !== $organisation->id) { - return false; - } - - return $this->canManageOrganisation($user, $organisation); - } - - public function delete(User $user, RegistrationFieldTemplate $template, Organisation $organisation): bool - { - if ($template->organisation_id !== $organisation->id) { - return false; - } - - return $this->canManageOrganisation($user, $organisation); - } - - private function canManageOrganisation(User $user, Organisation $organisation): bool - { - if ($user->hasRole('super_admin')) { - return true; - } - - return $organisation->users() - ->where('user_id', $user->id) - ->wherePivot('role', 'org_admin') - ->exists(); - } -} diff --git a/api/app/Policies/RegistrationFormFieldPolicy.php b/api/app/Policies/RegistrationFormFieldPolicy.php deleted file mode 100644 index ade9fe7b..00000000 --- a/api/app/Policies/RegistrationFormFieldPolicy.php +++ /dev/null @@ -1,84 +0,0 @@ -belongsToOrganisation($user, $event); - } - - public function view(User $user, RegistrationFormField $field, Event $event): bool - { - if ($field->event_id !== $event->id) { - return false; - } - - return $this->belongsToOrganisation($user, $event); - } - - public function create(User $user, Event $event): bool - { - return $this->canManageEvent($user, $event); - } - - public function update(User $user, RegistrationFormField $field, Event $event): bool - { - if ($field->event_id !== $event->id) { - return false; - } - - return $this->canManageEvent($user, $event); - } - - public function delete(User $user, RegistrationFormField $field, Event $event): bool - { - if ($field->event_id !== $event->id) { - return false; - } - - return $this->canManageEvent($user, $event); - } - - public function reorder(User $user, Event $event): bool - { - return $this->canManageEvent($user, $event); - } - - private function belongsToOrganisation(User $user, Event $event): bool - { - if ($user->hasRole('super_admin')) { - return true; - } - - return $event->organisation->users()->where('user_id', $user->id)->exists(); - } - - private function canManageEvent(User $user, Event $event): bool - { - if ($user->hasRole('super_admin')) { - return true; - } - - $isOrgAdmin = $event->organisation->users() - ->where('user_id', $user->id) - ->wherePivot('role', 'org_admin') - ->exists(); - - if ($isOrgAdmin) { - return true; - } - - return $event->users() - ->where('user_id', $user->id) - ->wherePivot('role', 'event_manager') - ->exists(); - } -} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 2643f1e4..4a547843 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -19,12 +19,9 @@ use App\Models\Organisation; use App\Models\OrganisationEmailSettings; use App\Models\OrganisationEmailTemplate; use App\Models\Person; -use App\Models\PersonFieldValue; use App\Models\PersonIdentityMatch; use App\Models\PersonSectionPreference; use App\Models\PersonTag; -use App\Models\RegistrationFieldTemplate; -use App\Models\RegistrationFormField; use App\Models\Shift; use App\Models\ShiftAssignment; use App\Models\ShiftWaitlist; @@ -92,12 +89,9 @@ class AppServiceProvider extends ServiceProvider 'mfa_email_code' => MfaEmailCode::class, 'organisation_email_settings' => OrganisationEmailSettings::class, 'organisation_email_template' => OrganisationEmailTemplate::class, - 'person_field_value' => PersonFieldValue::class, 'person_identity_match' => PersonIdentityMatch::class, 'person_section_preference' => PersonSectionPreference::class, 'person_tag' => PersonTag::class, - 'registration_field_template' => RegistrationFieldTemplate::class, - 'registration_form_field' => RegistrationFormField::class, 'shift' => Shift::class, 'shift_assignment' => ShiftAssignment::class, 'shift_waitlist' => ShiftWaitlist::class, diff --git a/api/app/Services/PersonIdentityService.php b/api/app/Services/PersonIdentityService.php index 66bcbd8c..843e530e 100644 --- a/api/app/Services/PersonIdentityService.php +++ b/api/app/Services/PersonIdentityService.php @@ -357,9 +357,6 @@ final class PersonIdentityService ]); }); - // Sync registration tags - $this->syncRegistrationTags($person); - activity('identity') ->causedBy($resolvedBy) ->performedOn($person) @@ -498,9 +495,6 @@ final class PersonIdentityService ]); }); - // Sync registration tags - $this->syncRegistrationTags($person); - activity('identity') ->causedBy($linkedBy) ->performedOn($person) @@ -550,23 +544,4 @@ final class PersonIdentityService return $person->fresh(); } - /** - * Sync registration tags when identity is confirmed. - */ - private function syncRegistrationTags(Person $person): void - { - if ($person->user_id === null) { - return; - } - - try { - app(TagSyncService::class)->syncFromRegistration($person); - } catch (\Exception $e) { - Log::warning('Failed to sync registration tags on identity confirm', [ - 'person_id' => $person->id, - 'user_id' => $person->user_id, - 'error' => $e->getMessage(), - ]); - } - } } diff --git a/api/app/Services/PersonSectionPreferenceService.php b/api/app/Services/PersonSectionPreferenceService.php deleted file mode 100644 index 16a54996..00000000 --- a/api/app/Services/PersonSectionPreferenceService.php +++ /dev/null @@ -1,51 +0,0 @@ -id) - ->with('festivalSection') - ->orderBy('priority') - ->get(); - } - - public function replacePreferences(Person $person, array $preferences): void - { - $old = PersonSectionPreference::where('person_id', $person->id)->get()->toArray(); - - DB::transaction(function () use ($person, $preferences): void { - PersonSectionPreference::where('person_id', $person->id)->delete(); - - foreach ($preferences as $pref) { - PersonSectionPreference::create([ - 'person_id' => $person->id, - 'festival_section_id' => $pref['festival_section_id'], - 'priority' => $pref['priority'], - ]); - } - }); - - $activityLogger = activity('section_preferences') - ->performedOn($person) - ->withProperties([ - 'old' => $old, - 'attributes' => $preferences, - ]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('person.section_preferences.replaced'); - } -} diff --git a/api/app/Services/RegistrationFieldTemplateService.php b/api/app/Services/RegistrationFieldTemplateService.php deleted file mode 100644 index ae095fa7..00000000 --- a/api/app/Services/RegistrationFieldTemplateService.php +++ /dev/null @@ -1,215 +0,0 @@ -registrationFieldTemplates() - ->ordered() - ->get(); - } - - public function createTemplate(Organisation $organisation, array $data): RegistrationFieldTemplate - { - $data['slug'] = $this->generateUniqueSlug($organisation, $data['label']); - - if (!isset($data['display_width'])) { - $fieldType = RegistrationFieldType::from($data['field_type']); - $data['display_width'] = FieldDisplayWidth::defaultForFieldType($fieldType)->value; - } - - $template = $organisation->registrationFieldTemplates()->create($data); - - $activityLogger = activity('registration_templates') - ->performedOn($template) - ->withProperties(['attributes' => $data]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_template.created'); - - return $template; - } - - public function updateTemplate(RegistrationFieldTemplate $template, array $data): RegistrationFieldTemplate - { - $old = $template->toArray(); - - if (isset($data['label']) && $data['label'] !== $template->label) { - $data['slug'] = $this->generateUniqueSlug($template->organisation, $data['label'], $template->id); - } - - $template->update($data); - - $activityLogger = activity('registration_templates') - ->performedOn($template) - ->withProperties(['old' => $old, 'attributes' => $data]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_template.updated'); - - return $template->fresh(); - } - - public function deleteTemplate(RegistrationFieldTemplate $template): void - { - if ($template->is_system) { - $template->update(['is_active' => false]); - - $activityLogger = activity('registration_templates') - ->performedOn($template); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_template.deactivated'); - - return; - } - - $activityLogger = activity('registration_templates') - ->withProperties(['deleted_template' => $template->toArray()]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_template.deleted'); - - $template->delete(); - } - - public function createFieldFromTemplate(Event $event, RegistrationFieldTemplate $template): RegistrationFormField - { - $slug = $this->generateUniqueFieldSlug($event, $template->label); - - $maxOrder = RegistrationFormField::where('event_id', $event->id)->max('sort_order') ?? -1; - - $field = RegistrationFormField::create([ - 'event_id' => $event->id, - 'label' => $template->label, - 'slug' => $slug, - 'field_type' => $template->field_type, - 'options' => $template->options, - 'tag_categories' => $template->tag_categories, - 'is_required' => $template->is_required, - 'is_portal_visible' => $template->is_portal_visible, - 'is_admin_only' => $template->is_admin_only, - 'is_filterable' => $template->is_filterable, - 'help_text' => $template->help_text, - 'sort_order' => $maxOrder + 1, - 'display_width' => $template->display_width, - ]); - - $activityLogger = activity('registration_fields') - ->performedOn($field) - ->withProperties(['from_template_id' => $template->id]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_field.created_from_template'); - - return $field; - } - - /** - * Seed system templates for a newly created organisation. - */ - public static function seedSystemTemplates(Organisation $organisation): void - { - $templates = [ - ['label' => 'Persoonlijke voorkeuren', 'field_type' => 'heading', 'help_text' => 'Vertel ons wat we over jou moeten weten', 'display_width' => 'full', 'sort_order' => 1], - ['label' => 'Shirtmaat', 'field_type' => 'select', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 2], - ['label' => 'Dieetwensen', 'field_type' => 'multiselect', 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', 'Geen pinda\'s', 'Geen noten'], 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 3], - ['label' => 'Vergoeding', 'field_type' => 'heading', 'help_text' => 'Kies hoe je wilt worden bedankt voor je inzet', 'display_width' => 'full', 'sort_order' => 4], - ['label' => 'Vergoedingstype', 'field_type' => 'radio', 'options' => [ - ['label' => 'Pro Deo', 'description' => 'Je werkt als vrijwilliger zonder financiële vergoeding'], - ['label' => 'Entreeticket', 'description' => 'Je ontvangt een gratis festivalticket als dank voor je inzet'], - ['label' => 'Vrijwilligersvergoeding', 'description' => 'Je ontvangt een vergoeding conform de vrijwilligersregeling'], - ], 'is_required' => true, 'display_width' => 'full', 'sort_order' => 5], - ['label' => 'Noodcontact', 'field_type' => 'heading', 'help_text' => 'Wie kunnen we bereiken bij calamiteiten?', 'display_width' => 'full', 'sort_order' => 6], - ['label' => 'Noodcontact naam', 'field_type' => 'text', 'display_width' => 'half', 'sort_order' => 7], - ['label' => 'Noodcontact telefoon', 'field_type' => 'text', 'display_width' => 'half', 'sort_order' => 8], - ['label' => 'Ervaring & vaardigheden', 'field_type' => 'heading', 'help_text' => 'Welke diploma\'s en skills heb je?', 'display_width' => 'full', 'sort_order' => 9], - ['label' => 'EHBO / BHV diploma', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 10], - ['label' => 'Rijbewijs', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 11], - ['label' => 'Eerder vrijwilliger geweest', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 12], - ['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_categories' => null, 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 13], - ['label' => 'Aanvullende informatie', 'field_type' => 'heading', 'display_width' => 'full', 'sort_order' => 14], - ['label' => 'Toestemming gegevensverwerking', 'field_type' => 'boolean', 'is_required' => true, 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens ten behoeve van de organisatie van dit evenement, conform de Algemene Verordening Gegevensbescherming (AVG).', 'display_width' => 'full', 'sort_order' => 15], - ['label' => 'Opmerkingen', 'field_type' => 'textarea', 'display_width' => 'full', 'sort_order' => 16], - ]; - - foreach ($templates as $data) { - $organisation->registrationFieldTemplates()->create([ - ...$data, - 'slug' => Str::slug($data['label']), - 'is_system' => true, - 'is_active' => true, - 'is_required' => $data['is_required'] ?? false, - 'is_filterable' => $data['is_filterable'] ?? false, - 'is_portal_visible' => true, - 'is_admin_only' => false, - 'display_width' => $data['display_width'] ?? 'full', - ]); - } - } - - private function generateUniqueSlug(Organisation $organisation, string $label, ?string $excludeId = null): string - { - $base = Str::slug($label); - $slug = $base; - $counter = 1; - - while (true) { - $query = RegistrationFieldTemplate::where('organisation_id', $organisation->id) - ->where('slug', $slug); - - if ($excludeId) { - $query->where('id', '!=', $excludeId); - } - - if (!$query->exists()) { - return $slug; - } - - $counter++; - $slug = "{$base}-{$counter}"; - } - } - - private function generateUniqueFieldSlug(Event $event, string $label): string - { - $base = Str::slug($label); - $slug = $base; - $counter = 1; - - while (RegistrationFormField::where('event_id', $event->id)->where('slug', $slug)->exists()) { - $counter++; - $slug = "{$base}-{$counter}"; - } - - return $slug; - } -} diff --git a/api/app/Services/RegistrationFormFieldService.php b/api/app/Services/RegistrationFormFieldService.php deleted file mode 100644 index 1a14f7bd..00000000 --- a/api/app/Services/RegistrationFormFieldService.php +++ /dev/null @@ -1,225 +0,0 @@ -id) - ->ordered() - ->get(); - } - - public function createField(Event $event, array $data): RegistrationFormField - { - $data['slug'] = $this->generateUniqueSlug($event, $data['label']); - - if (!isset($data['display_width'])) { - $fieldType = RegistrationFieldType::from($data['field_type']); - $data['display_width'] = FieldDisplayWidth::defaultForFieldType($fieldType)->value; - } - - $field = RegistrationFormField::create([ - 'event_id' => $event->id, - ...$data, - ]); - - $activityLogger = activity('registration_fields') - ->performedOn($field) - ->withProperties(['attributes' => $data]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_field.created'); - - return $field; - } - - public function updateField(RegistrationFormField $field, array $data): RegistrationFormField - { - $old = $field->toArray(); - - if (isset($data['label']) && $data['label'] !== $field->label) { - $data['slug'] = $this->generateUniqueSlug($field->event, $data['label'], $field->id); - } - - $field->update($data); - - $activityLogger = activity('registration_fields') - ->performedOn($field) - ->withProperties(['old' => $old, 'attributes' => $data]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_field.updated'); - - return $field->fresh(); - } - - public function deleteField(RegistrationFormField $field): void - { - $activityLogger = activity('registration_fields') - ->withProperties(['deleted_field' => $field->toArray()]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_field.deleted'); - - $field->delete(); - } - - public function reorderFields(Event $event, array $orderedIds): void - { - DB::transaction(function () use ($event, $orderedIds): void { - foreach ($orderedIds as $index => $id) { - RegistrationFormField::where('id', $id) - ->where('event_id', $event->id) - ->update(['sort_order' => $index]); - } - }); - } - - public function upsertPersonValues(Person $person, array $values): void - { - $fields = RegistrationFormField::where('event_id', $person->event_id) - ->get() - ->keyBy('slug'); - - DB::transaction(function () use ($person, $values, $fields): void { - foreach ($values as $slug => $rawValue) { - $field = $fields->get($slug); - if ($field === null || $field->field_type->isStructural()) { - continue; - } - - $data = ['person_id' => $person->id, 'registration_form_field_id' => $field->id]; - - if ($field->isMultiValue()) { - $data['value'] = null; - $data['selected_options'] = is_array($rawValue) ? $rawValue : [$rawValue]; - } else { - $data['value'] = $rawValue === null ? null : (string) $rawValue; - $data['selected_options'] = null; - } - - PersonFieldValue::updateOrCreate( - ['person_id' => $person->id, 'registration_form_field_id' => $field->id], - $data, - ); - } - }); - - $activityLogger = activity('registration_values') - ->performedOn($person) - ->withProperties(['slugs' => array_keys($values)]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('person.field_values.upserted'); - - $this->tagSyncService->syncFromRegistration($person); - } - - public function getPersonValues(Person $person): Collection - { - return PersonFieldValue::where('person_id', $person->id) - ->with('registrationFormField') - ->get(); - } - - public function importFromEvent(Event $targetEvent, Event $sourceEvent): Collection - { - $sourceFields = RegistrationFormField::where('event_id', $sourceEvent->id) - ->ordered() - ->get(); - - $maxOrder = RegistrationFormField::where('event_id', $targetEvent->id)->max('sort_order') ?? -1; - - $created = collect(); - - foreach ($sourceFields as $sourceField) { - $slug = $this->generateUniqueSlug($targetEvent, $sourceField->label); - - $field = RegistrationFormField::create([ - 'event_id' => $targetEvent->id, - 'label' => $sourceField->label, - 'slug' => $slug, - 'field_type' => $sourceField->field_type, - 'options' => $sourceField->options, - 'tag_categories' => $sourceField->tag_categories, - 'is_required' => $sourceField->is_required, - 'is_portal_visible' => $sourceField->is_portal_visible, - 'is_admin_only' => $sourceField->is_admin_only, - 'is_filterable' => $sourceField->is_filterable, - 'help_text' => $sourceField->help_text, - 'sort_order' => ++$maxOrder, - 'display_width' => $sourceField->display_width, - ]); - - $created->push($field); - } - - $activityLogger = activity('registration_fields') - ->withProperties([ - 'source_event_id' => $sourceEvent->id, - 'target_event_id' => $targetEvent->id, - 'fields_copied' => $created->count(), - ]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('registration_field.imported_from_event'); - - return $created; - } - - private function generateUniqueSlug(Event $event, string $label, ?string $excludeId = null): string - { - $base = Str::slug($label); - $slug = $base; - $counter = 1; - - while (true) { - $query = RegistrationFormField::where('event_id', $event->id) - ->where('slug', $slug); - - if ($excludeId) { - $query->where('id', '!=', $excludeId); - } - - if (!$query->exists()) { - return $slug; - } - - $counter++; - $slug = "{$base}-{$counter}"; - } - } -} diff --git a/api/app/Services/TagSyncService.php b/api/app/Services/TagSyncService.php deleted file mode 100644 index 45951eb1..00000000 --- a/api/app/Services/TagSyncService.php +++ /dev/null @@ -1,94 +0,0 @@ -user_id === null) { - return; - } - - $organisationId = $person->event?->organisation_id; - if ($organisationId === null) { - return; - } - - $tagPickerFields = RegistrationFormField::where('event_id', $person->event_id) - ->where('field_type', RegistrationFieldType::TAG_PICKER) - ->pluck('id'); - - if ($tagPickerFields->isEmpty()) { - return; - } - - $selectedTagIds = PersonFieldValue::where('person_id', $person->id) - ->whereIn('registration_form_field_id', $tagPickerFields) - ->whereNotNull('selected_options') - ->get() - ->flatMap(fn (PersonFieldValue $v) => $v->selected_options ?? []) - ->unique() - ->values() - ->all(); - - DB::transaction(function () use ($person, $organisationId, $selectedTagIds): void { - $existingSelfReported = UserOrganisationTag::where('user_id', $person->user_id) - ->where('organisation_id', $organisationId) - ->where('source', 'self_reported') - ->pluck('person_tag_id') - ->all(); - - $toAdd = array_diff($selectedTagIds, $existingSelfReported); - $toRemove = array_diff($existingSelfReported, $selectedTagIds); - - if (!empty($toRemove)) { - UserOrganisationTag::where('user_id', $person->user_id) - ->where('organisation_id', $organisationId) - ->where('source', 'self_reported') - ->whereIn('person_tag_id', $toRemove) - ->delete(); - } - - foreach ($toAdd as $tagId) { - UserOrganisationTag::create([ - 'user_id' => $person->user_id, - 'organisation_id' => $organisationId, - 'person_tag_id' => $tagId, - 'source' => 'self_reported', - 'assigned_at' => now(), - ]); - } - }); - - $activityLogger = activity('tag_sync') - ->performedOn($person) - ->withProperties([ - 'synced_tag_ids' => $selectedTagIds, - 'organisation_id' => $organisationId, - ]); - - if (auth()->user()) { - $activityLogger->causedBy(auth()->user()); - } - - $activityLogger->log('person.tags.synced_from_registration'); - } -} diff --git a/api/app/Services/VolunteerRegistrationService.php b/api/app/Services/VolunteerRegistrationService.php deleted file mode 100644 index 3107fb56..00000000 --- a/api/app/Services/VolunteerRegistrationService.php +++ /dev/null @@ -1,204 +0,0 @@ - $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 = strtolower($user?->email ?? $validated['email']); - - $this->checkDuplicateRegistration($festivalEvent, $email); - - $volunteerCrowdType = $this->resolveVolunteerCrowdType($event); - - $person = DB::transaction(function () use ($festivalEvent, $validated, $user, $email, $volunteerCrowdType): Person { - $person = Person::updateOrCreate( - [ - 'event_id' => $festivalEvent->id, - 'email' => $email, - ], - [ - 'crowd_type_id' => $volunteerCrowdType->id, - 'first_name' => $validated['first_name'] ?? $user->first_name, - 'last_name' => $validated['last_name'] ?? $user->last_name, - 'phone' => $validated['phone'] ?? null, - 'date_of_birth' => $validated['date_of_birth'] ?? null, - 'status' => PersonStatus::PENDING, - 'registration_source' => 'self', - 'custom_fields' => [ - 'tshirt_size' => $validated['tshirt_size'] ?? null, - 'first_aid' => $validated['first_aid'] ?? false, - 'allergies' => $validated['allergies'] ?? null, - 'driving_licence' => $validated['driving_licence'] ?? false, - 'motivation' => $validated['motivation'] ?? null, - 'motivation_other' => $validated['motivation_other'] ?? null, - ], - ] - ); - - // Link to authenticated user directly (they already have an account) - if ($user) { - $person->user_id = $user->id; - $person->save(); - } - - $this->syncAvailabilities($person, $festivalEvent, $validated['availabilities'] ?? []); - - if (!empty($validated['field_values'])) { - $this->registrationFormFieldService->upsertPersonValues( - $person, - $validated['field_values'] - ); - } - - if (!empty($validated['section_preferences'])) { - $this->personSectionPreferenceService->replacePreferences( - $person, - $validated['section_preferences'] - ); - } - - // Trigger tag sync if user_id is known - if ($person->user_id) { - $this->tagSyncService->syncFromRegistration($person); - } - - $source = $user ? '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) { - $activityLogger->causedBy($user); - } - - $activityLogger->log('person.registered'); - - return $person; - }); - - // Send confirmation email (queued, outside transaction) - Mail::to($person->email)->queue(new RegistrationConfirmationMail($person, $festivalEvent)); - - 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->value) { - 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(), - ]); - } - } -} diff --git a/api/database/factories/RegistrationFieldTemplateFactory.php b/api/database/factories/RegistrationFieldTemplateFactory.php deleted file mode 100644 index 9ee5c531..00000000 --- a/api/database/factories/RegistrationFieldTemplateFactory.php +++ /dev/null @@ -1,97 +0,0 @@ - */ -final class RegistrationFieldTemplateFactory extends Factory -{ - protected $model = RegistrationFieldTemplate::class; - - /** @return array */ - public function definition(): array - { - $label = fake('nl_NL')->unique()->words(2, true); - - return [ - 'organisation_id' => Organisation::factory(), - 'label' => ucfirst($label), - 'slug' => Str::slug($label), - 'field_type' => RegistrationFieldType::TEXT, - 'options' => null, - 'tag_categories' => null, - 'is_required' => false, - 'is_filterable' => false, - 'is_portal_visible' => true, - 'is_admin_only' => false, - 'help_text' => null, - 'sort_order' => fake()->numberBetween(0, 20), - 'display_width' => FieldDisplayWidth::FULL, - 'is_system' => false, - 'is_active' => true, - ]; - } - - public function system(): static - { - return $this->state(fn () => ['is_system' => true]); - } - - public function inactive(): static - { - return $this->state(fn () => ['is_active' => false]); - } - - public function selectField(): static - { - return $this->state(fn () => [ - 'label' => 'Shirtmaat', - 'slug' => 'shirtmaat', - 'field_type' => RegistrationFieldType::SELECT, - 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], - 'is_filterable' => true, - 'display_width' => FieldDisplayWidth::HALF, - ]); - } - - public function booleanField(): static - { - return $this->state(fn () => [ - 'label' => 'EHBO / BHV diploma', - 'slug' => 'ehbo-bhv-diploma', - 'field_type' => RegistrationFieldType::BOOLEAN, - 'is_filterable' => true, - 'display_width' => FieldDisplayWidth::HALF, - ]); - } - - public function tagPickerField(): static - { - return $this->state(fn () => [ - 'label' => 'Certificaten & vaardigheden', - 'slug' => 'certificaten-vaardigheden', - 'field_type' => RegistrationFieldType::TAG_PICKER, - 'is_filterable' => true, - 'display_width' => FieldDisplayWidth::FULL, - ]); - } - - public function headingField(): static - { - return $this->state(fn () => [ - 'label' => 'Persoonlijke voorkeuren', - 'slug' => 'persoonlijke-voorkeuren', - 'field_type' => RegistrationFieldType::HEADING, - 'help_text' => 'Vertel ons wat we over jou moeten weten', - 'display_width' => FieldDisplayWidth::FULL, - ]); - } -} diff --git a/api/database/factories/RegistrationFormFieldFactory.php b/api/database/factories/RegistrationFormFieldFactory.php deleted file mode 100644 index 0dde576c..00000000 --- a/api/database/factories/RegistrationFormFieldFactory.php +++ /dev/null @@ -1,133 +0,0 @@ - */ -final class RegistrationFormFieldFactory extends Factory -{ - protected $model = RegistrationFormField::class; - - /** @return array */ - public function definition(): array - { - $label = fake('nl_NL')->unique()->words(2, true); - - return [ - 'event_id' => Event::factory(), - 'label' => ucfirst($label), - 'slug' => Str::slug($label), - 'field_type' => RegistrationFieldType::TEXT, - 'options' => null, - 'tag_categories' => null, - 'is_required' => false, - 'is_portal_visible' => true, - 'is_admin_only' => false, - 'is_filterable' => false, - 'help_text' => null, - 'sort_order' => fake()->numberBetween(0, 20), - 'display_width' => FieldDisplayWidth::FULL, - ]; - } - - public function textField(): static - { - return $this->state(fn () => [ - 'label' => 'Noodcontact naam', - 'slug' => 'noodcontact-naam', - 'field_type' => RegistrationFieldType::TEXT, - 'display_width' => FieldDisplayWidth::HALF, - ]); - } - - public function selectField(): static - { - return $this->state(fn () => [ - 'label' => 'Shirtmaat', - 'slug' => 'shirtmaat', - 'field_type' => RegistrationFieldType::SELECT, - 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], - 'is_filterable' => true, - 'display_width' => FieldDisplayWidth::HALF, - ]); - } - - public function multiselectField(): static - { - return $this->state(fn () => [ - 'label' => 'Dieetwensen', - 'slug' => 'dieetwensen', - 'field_type' => RegistrationFieldType::MULTISELECT, - 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', 'Geen pinda\'s', 'Geen noten'], - 'is_filterable' => true, - 'display_width' => FieldDisplayWidth::FULL, - ]); - } - - public function booleanField(): static - { - return $this->state(fn () => [ - 'label' => 'Toestemming gegevensverwerking', - 'slug' => 'toestemming-gegevensverwerking', - 'field_type' => RegistrationFieldType::BOOLEAN, - 'is_required' => true, - 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens conform de AVG.', - 'display_width' => FieldDisplayWidth::FULL, - ]); - } - - public function tagPickerField(): static - { - return $this->state(fn () => [ - 'label' => 'Vaardigheden', - 'slug' => 'vaardigheden', - 'field_type' => RegistrationFieldType::TAG_PICKER, - 'is_filterable' => true, - 'display_width' => FieldDisplayWidth::FULL, - ]); - } - - public function radioField(): static - { - return $this->state(fn () => [ - 'label' => 'Vergoeding', - 'slug' => 'vergoeding', - 'field_type' => RegistrationFieldType::RADIO, - 'options' => [ - ['label' => 'Pro Deo', 'description' => 'Je werkt als vrijwilliger zonder financiële vergoeding'], - ['label' => 'Entreeticket', 'description' => 'Je ontvangt een gratis festivalticket als dank voor je inzet'], - ['label' => 'Vrijwilligersvergoeding', 'description' => 'Je ontvangt een vergoeding conform de vrijwilligersregeling'], - ], - 'display_width' => FieldDisplayWidth::FULL, - ]); - } - - public function textareaField(): static - { - return $this->state(fn () => [ - 'label' => 'Opmerkingen', - 'slug' => 'opmerkingen', - 'field_type' => RegistrationFieldType::TEXTAREA, - 'display_width' => FieldDisplayWidth::FULL, - ]); - } - - public function headingField(): static - { - return $this->state(fn () => [ - 'label' => 'Persoonlijke voorkeuren', - 'slug' => 'persoonlijke-voorkeuren', - 'field_type' => RegistrationFieldType::HEADING, - 'help_text' => 'Vertel ons wat we over jou moeten weten', - 'display_width' => FieldDisplayWidth::FULL, - ]); - } -} diff --git a/api/database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php b/api/database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php new file mode 100644 index 00000000..3a35c2d8 --- /dev/null +++ b/api/database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php @@ -0,0 +1,39 @@ +middleware('throttle:10,1'); -Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class)->middleware('throttle:5,1'); Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1'); // Platform Admin routes @@ -216,10 +208,6 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { ->except(['show']); Route::get('person-tag-categories', [PersonTagController::class, 'categories']); - // Registration field templates (organisation settings) - Route::apiResource('registration-field-templates', RegistrationFieldTemplateController::class) - ->except(['show']); - // User tag assignments Route::get('users/{user}/tags', [UserOrganisationTagController::class, 'index']); Route::post('users/{user}/tags', [UserOrganisationTagController::class, 'store']); @@ -291,21 +279,6 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { Route::get('persons/{person}/availabilities', [VolunteerAvailabilityController::class, 'index']); Route::post('persons/{person}/availabilities/sync', [VolunteerAvailabilityController::class, 'sync']); - // Person field values - Route::get('persons/{person}/field-values', [PersonFieldValueController::class, 'index']); - Route::put('persons/{person}/field-values', [PersonFieldValueController::class, 'upsert']); - - // Person section preferences - Route::get('persons/{person}/section-preferences', [PersonSectionPreferenceController::class, 'index']); - Route::put('persons/{person}/section-preferences', [PersonSectionPreferenceController::class, 'replace']); - - // Registration form fields (event settings) - Route::apiResource('registration-fields', RegistrationFormFieldController::class) - ->except(['show']); - Route::post('registration-fields/reorder', [RegistrationFormFieldController::class, 'reorder']); - Route::post('registration-fields/from-template', [RegistrationFormFieldController::class, 'fromTemplate']); - Route::post('registration-fields/import-from-event', [RegistrationFormFieldController::class, 'importFromEvent']); - // Crowd lists Route::apiResource('crowd-lists', CrowdListController::class) ->except(['show']); diff --git a/api/tests/Feature/Api/V1/EventImageUploadTest.php b/api/tests/Feature/Api/V1/EventImageUploadTest.php index 629c8308..81e26fd2 100644 --- a/api/tests/Feature/Api/V1/EventImageUploadTest.php +++ b/api/tests/Feature/Api/V1/EventImageUploadTest.php @@ -137,41 +137,6 @@ class EventImageUploadTest extends TestCase $response->assertNotFound(); } - public function test_registration_data_includes_branding_fields(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - 'slug' => 'branding-test-event', - 'registration_welcome_text' => 'Welcome to our event!', - 'registration_banner_url' => 'https://example.com/banner.jpg', - 'registration_logo_url' => 'https://example.com/logo.png', - ]); - - $response = $this->getJson('/api/v1/public/events/branding-test-event/registration-data'); - - $response->assertOk() - ->assertJsonPath('data.event.registration_welcome_text', 'Welcome to our event!') - ->assertJsonPath('data.event.registration_banner_url', 'https://example.com/banner.jpg') - ->assertJsonPath('data.event.registration_logo_url', 'https://example.com/logo.png'); - } - - public function test_registration_data_includes_null_branding_fields(): void - { - Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - 'slug' => 'no-branding-event', - ]); - - $response = $this->getJson('/api/v1/public/events/no-branding-event/registration-data'); - - $response->assertOk() - ->assertJsonPath('data.event.registration_welcome_text', null) - ->assertJsonPath('data.event.registration_banner_url', null) - ->assertJsonPath('data.event.registration_logo_url', null); - } - public function test_event_update_saves_welcome_text(): void { Sanctum::actingAs($this->orgAdmin); diff --git a/api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php b/api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php deleted file mode 100644 index 6b9983ea..00000000 --- a/api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php +++ /dev/null @@ -1,314 +0,0 @@ -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->orgAdmin = User::factory()->create(); - $this->orgAdmin->assignRole('super_admin'); - $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); - } - - /** - * Golden path: register → person without user → approve → user created → portal works. - */ - public function test_full_flow_register_approve_creates_user_and_portal_works(): void - { - Mail::fake(); - - // ── Step 1: Volunteer registers (no password) ── - $regResponse = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Vrijwilliger', - 'last_name' => 'Test', - 'email' => 'vrijwilliger@test.nl', - ]); - - $regResponse->assertStatus(201); - - $person = Person::where('email', 'vrijwilliger@test.nl')->first(); - $this->assertNotNull($person); - $this->assertNull($person->user_id, 'Person should NOT have user_id after registration'); - $this->assertEquals('pending', $person->status); - $this->assertEquals('self', $person->registration_source); - - // No user account should exist yet - $this->assertDatabaseMissing('users', ['email' => 'vrijwilliger@test.nl']); - - // ── Step 2: Organizer approves ── - Sanctum::actingAs($this->orgAdmin); - - $approveResponse = $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve" - ); - - $approveResponse->assertOk(); - - // Approval should have created user account and linked it - $person->refresh(); - $this->assertEquals('approved', $person->status); - $this->assertNotNull($person->user_id, 'user_id should be set after approval'); - - $user = User::where('email', 'vrijwilliger@test.nl')->first(); - $this->assertNotNull($user, 'User account should be created on approval'); - $this->assertEquals($person->user_id, $user->id); - - // ── Step 3: Volunteer accesses portal ── - Sanctum::actingAs($user); - - $meResponse = $this->getJson('/api/v1/auth/me'); - $meResponse->assertOk(); - $meResponse->assertJsonCount(1, 'data.portal_events'); - $meResponse->assertJsonPath('data.portal_events.0.event_id', $this->event->id); - $meResponse->assertJsonPath('data.portal_events.0.person_status', 'approved'); - - $portalMeResponse = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); - $portalMeResponse->assertOk(); - $portalMeResponse->assertJsonPath('data.email', 'vrijwilliger@test.nl'); - $portalMeResponse->assertJsonPath('data.status', 'approved'); - } - - /** - * Approval links existing user by person.email. - */ - public function test_approve_links_existing_user_by_person_email(): void - { - Mail::fake(); - - // Pre-existing user account - $existingUser = User::factory()->create(['email' => 'bestaand@test.nl']); - - // Register with same email - $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Bestaand', - 'last_name' => 'Lid', - 'email' => 'bestaand@test.nl', - ])->assertStatus(201); - - $person = Person::where('email', 'bestaand@test.nl')->first(); - $this->assertNull($person->user_id); - - // Approve - Sanctum::actingAs($this->orgAdmin); - $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve" - )->assertOk(); - - // Should link to existing user, not create a new one - $person->refresh(); - $this->assertEquals($existingUser->id, $person->user_id); - $this->assertEquals(1, User::where('email', 'bestaand@test.nl')->count()); - } - - /** - * Festival hierarchy: register via sub-event, portal works with both IDs. - */ - public function test_full_flow_with_festival_sub_event(): void - { - Mail::fake(); - - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - ]); - $subEvent = Event::factory()->subEvent($festival)->create([ - 'status' => 'registration_open', - ]); - - // Register via sub-event - $this->postJson("/api/v1/events/{$subEvent->id}/volunteer-register", [ - 'first_name' => 'Festival', - 'last_name' => 'Ganger', - 'email' => 'festival@test.nl', - ])->assertStatus(201); - - $person = Person::where('email', 'festival@test.nl')->first(); - $this->assertEquals($festival->id, $person->event_id, 'Person should be linked to parent event'); - - // Approve - Sanctum::actingAs($this->orgAdmin); - $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/persons/{$person->id}/approve" - )->assertOk(); - - $person->refresh(); - $user = User::find($person->user_id); - - // Portal access - Sanctum::actingAs($user); - - $this->getJson("/api/v1/portal/me?event_id={$festival->id}") - ->assertOk() - ->assertJsonPath('data.email', 'festival@test.nl'); - - $this->getJson("/api/v1/portal/me?event_id={$subEvent->id}") - ->assertOk() - ->assertJsonPath('data.email', 'festival@test.nl'); - } - - /** - * Authenticated registration still links user_id directly. - */ - public function test_authenticated_registration_links_user_directly(): void - { - Mail::fake(); - - $user = User::factory()->create([ - 'first_name' => 'Ingelogd', - 'last_name' => 'Gebruiker', - 'email' => 'ingelogd@test.nl', - ]); - $this->organisation->users()->attach($user, ['role' => 'org_member']); - Sanctum::actingAs($user); - - $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", []) - ->assertStatus(201); - - $person = Person::where('email', 'ingelogd@test.nl')->first(); - $this->assertEquals($user->id, $person->user_id, 'Authenticated registration should set user_id directly'); - } - - /** - * Approval skips account creation if person already has user_id (authenticated registration). - */ - public function test_approve_skips_account_creation_if_user_already_linked(): void - { - Mail::fake(); - - $user = User::factory()->create(['email' => 'al-gelinkt@test.nl']); - $this->organisation->users()->attach($user, ['role' => 'org_member']); - - // Authenticated registration sets user_id - Sanctum::actingAs($user); - $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", []) - ->assertStatus(201); - - $person = Person::where('email', 'al-gelinkt@test.nl')->first(); - $this->assertEquals($user->id, $person->user_id); - - // Approve — should not create another user - Sanctum::actingAs($this->orgAdmin); - $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve" - )->assertOk(); - - $person->refresh(); - $this->assertEquals($user->id, $person->user_id); - $this->assertEquals(1, User::where('email', 'al-gelinkt@test.nl')->count()); - } - - /** - * Organizer-created person → identity match confirmed → portal/me works. - */ - public function test_organizer_created_person_then_identity_linked(): void - { - $user = User::factory()->create([ - 'first_name' => 'Handmatig', - 'last_name' => 'Toegevoegd', - 'email' => 'handmatig@test.nl', - ]); - $this->organisation->users()->attach($user, ['role' => 'org_member']); - - Sanctum::actingAs($this->orgAdmin); - - $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons", - [ - 'crowd_type_id' => $this->volunteerCrowdType->id, - 'first_name' => 'Handmatig', - 'last_name' => 'Toegevoegd', - 'email' => 'handmatig@test.nl', - 'status' => 'approved', - ] - )->assertStatus(201); - - $person = Person::where('email', 'handmatig@test.nl')->first(); - $this->assertNull($person->user_id); - - // Portal fails without user link - Sanctum::actingAs($user); - $this->getJson("/api/v1/portal/me?event_id={$this->event->id}") - ->assertStatus(404); - - // Confirm identity match - $match = $person->pendingIdentityMatch; - $this->assertNotNull($match); - - Sanctum::actingAs($this->orgAdmin); - $this->postJson( - "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" - )->assertOk(); - - // Portal now works - Sanctum::actingAs($user); - $this->getJson("/api/v1/portal/me?event_id={$this->event->id}") - ->assertOk() - ->assertJsonPath('data.email', 'handmatig@test.nl'); - } - - /** - * Fuzzy name matching is skipped for self-registered persons. - */ - public function test_fuzzy_name_match_skipped_for_self_registered(): void - { - Mail::fake(); - - // Create a user with similar name but different email - $existingUser = User::factory()->create([ - 'first_name' => 'Jan', - 'last_name' => 'de Vries', - 'email' => 'jan.devries@other.nl', - ]); - $this->organisation->users()->attach($existingUser, ['role' => 'org_member']); - - // Self-register with same name but different email - $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Jan', - 'last_name' => 'de Vries', - 'email' => 'jan@voorbeeld.nl', - ])->assertStatus(201); - - $person = Person::where('email', 'jan@voorbeeld.nl')->first(); - - // Should NOT have a fuzzy name match (self-registered) - $this->assertNull($person->pendingIdentityMatch); - } -} diff --git a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php deleted file mode 100644 index f8f29d69..00000000 --- a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php +++ /dev/null @@ -1,332 +0,0 @@ -organisation = Organisation::factory()->create(); - } - - public function test_returns_registration_data_for_open_event(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - 'slug' => 'test-event-2026', - ]); - - $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([ - 'event_id' => $event->id, - 'person_type' => 'VOLUNTEER', - ]); - - TimeSlot::factory()->create([ - 'event_id' => $event->id, - 'person_type' => 'CREW', - ]); - - $response = $this->getJson('/api/v1/public/events/test-event-2026/registration-data'); - - $response->assertOk() - ->assertJsonPath('data.event.id', $event->id) - ->assertJsonPath('data.event.name', $event->name) - ->assertJsonCount(1, 'data.sections') - ->assertJsonPath('data.sections.0.id', $section->id) - ->assertJsonCount(1, 'data.time_slots') - ->assertJsonPath('data.time_slots.0.id', $timeSlot->id); - } - - public function test_returns_404_for_non_registration_open_event(): void - { - Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'draft', - 'slug' => 'draft-event', - ]); - - $response = $this->getJson('/api/v1/public/events/draft-event/registration-data'); - - $response->assertNotFound(); - } - - public function test_returns_404_for_nonexistent_slug(): void - { - $response = $this->getJson('/api/v1/public/events/does-not-exist/registration-data'); - - $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'); - } - - // ─── Registration Fields ─────────────────────────────────────────── - - public function test_registration_data_includes_registration_fields(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - 'slug' => 'fields-event', - ]); - - $field = RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $event->id, - 'is_portal_visible' => true, - 'is_admin_only' => false, - 'sort_order' => 0, - ]); - - RegistrationFormField::factory()->textareaField()->create([ - 'event_id' => $event->id, - 'is_portal_visible' => true, - 'is_admin_only' => false, - 'sort_order' => 1, - ]); - - $response = $this->getJson('/api/v1/public/events/fields-event/registration-data'); - - $response->assertOk() - ->assertJsonCount(2, 'data.registration_fields') - ->assertJsonPath('data.registration_fields.0.slug', $field->slug) - ->assertJsonPath('data.registration_fields.0.field_type', 'select') - ->assertJsonPath('data.registration_fields.0.is_required', false); - } - - public function test_registration_data_excludes_admin_only_fields(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - 'slug' => 'admin-fields-event', - ]); - - RegistrationFormField::factory()->create([ - 'event_id' => $event->id, - 'label' => 'Public field', - 'slug' => 'public-field', - 'is_portal_visible' => true, - 'is_admin_only' => false, - ]); - - // Admin-only field (should be excluded) - RegistrationFormField::factory()->create([ - 'event_id' => $event->id, - 'label' => 'Admin only field', - 'slug' => 'admin-only-field', - 'is_portal_visible' => true, - 'is_admin_only' => true, - ]); - - // Not portal visible (should be excluded) - RegistrationFormField::factory()->create([ - 'event_id' => $event->id, - 'label' => 'Hidden field', - 'slug' => 'hidden-field', - 'is_portal_visible' => false, - 'is_admin_only' => false, - ]); - - $response = $this->getJson('/api/v1/public/events/admin-fields-event/registration-data'); - - $response->assertOk() - ->assertJsonCount(1, 'data.registration_fields') - ->assertJsonPath('data.registration_fields.0.slug', 'public-field'); - } - - public function test_registration_data_includes_form_toggles(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - 'slug' => 'toggles-event', - 'registration_show_section_preferences' => true, - 'registration_show_availability' => false, - ]); - - $response = $this->getJson('/api/v1/public/events/toggles-event/registration-data'); - - $response->assertOk() - ->assertJsonPath('data.event.registration_show_section_preferences', true) - ->assertJsonPath('data.event.registration_show_availability', false); - } - - public function test_registration_data_includes_available_tags_for_tag_picker(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'status' => 'registration_open', - 'slug' => 'tags-event', - ]); - - $tag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'name' => 'EHBO', - 'category' => 'Certificaat', - 'is_active' => true, - ]); - - PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'name' => 'Barervaring', - 'category' => 'Ervaring', - 'is_active' => true, - ]); - - RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $event->id, - 'tag_categories' => ['Certificaat'], - 'is_portal_visible' => true, - 'is_admin_only' => false, - ]); - - $response = $this->getJson('/api/v1/public/events/tags-event/registration-data'); - - $response->assertOk() - ->assertJsonCount(1, 'data.registration_fields') - ->assertJsonCount(1, 'data.registration_fields.0.available_tags') - ->assertJsonPath('data.registration_fields.0.available_tags.0.name', 'EHBO'); - } -} diff --git a/api/tests/Feature/Api/V1/RegistrationSettingsTest.php b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php index 2729355b..d3bb7dfb 100644 --- a/api/tests/Feature/Api/V1/RegistrationSettingsTest.php +++ b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php @@ -195,44 +195,4 @@ class RegistrationSettingsTest extends TestCase ->assertJsonPath('data.0.section_count', 1); } - public function test_section_preferences_stored_in_table(): void - { - \Illuminate\Support\Facades\Mail::fake(); - - // 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, - ]); - - $section = FestivalSection::factory()->create([ - 'event_id' => $event->id, - 'name' => 'Backstage', - 'show_in_registration' => true, - ]); - - $response = $this->postJson("/api/v1/events/{$event->id}/volunteer-register", [ - 'first_name' => 'Test', - 'last_name' => 'Vrijwilliger', - 'email' => 'test-section-pref@example.nl', - 'password' => 'Wachtwoord1', - 'section_preferences' => [ - ['festival_section_id' => $section->id, 'priority' => 1], - ], - ]); - - $response->assertStatus(201); - - $person = \App\Models\Person::where('email', 'test-section-pref@example.nl')->first(); - - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $person->id, - 'festival_section_id' => $section->id, - 'priority' => 1, - ]); - } } diff --git a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php deleted file mode 100644 index 79165774..00000000 --- a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php +++ /dev/null @@ -1,762 +0,0 @@ -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 - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Jan', - 'last_name' => '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 - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Sophie', - 'last_name' => 'Bakker', - 'email' => 'sophie@voorbeeld.nl', - - ]); - - $response->assertStatus(201); - - $this->assertDatabaseHas('persons', [ - 'email' => 'sophie@voorbeeld.nl', - 'first_name' => 'Sophie', - 'last_name' => '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", [ - 'first_name' => 'Pieter', - 'last_name' => '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 - { - Mail::fake(); - $timeSlot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Fleur', - 'last_name' => '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 - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Daan', - 'last_name' => 'Mulder', - 'email' => 'daan@voorbeeld.nl', - - 'tshirt_size' => 'XL', - 'motivation' => 'Ik vind festivals geweldig.', - 'section_preferences' => [ - ['festival_section_id' => $this->section->id, '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->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $person->id, - 'festival_section_id' => $this->section->id, - 'priority' => 1, - ]); - } - - public function test_volunteer_can_register_with_date_of_birth(): void - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Mila', - 'last_name' => 'de Boer', - 'email' => 'mila@voorbeeld.nl', - - 'date_of_birth' => '1998-05-12', - ]); - - $response->assertStatus(201); - - $person = Person::where('email', 'mila@voorbeeld.nl')->first(); - $this->assertEquals('1998-05-12', $person->date_of_birth->format('Y-m-d')); - } - - public function test_volunteer_can_register_without_date_of_birth(): void - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Sem', - 'last_name' => 'van Beek', - 'email' => 'sem@voorbeeld.nl', - - ]); - - $response->assertStatus(201); - - $person = Person::where('email', 'sem@voorbeeld.nl')->first(); - $this->assertNull($person->date_of_birth); - } - - public function test_date_of_birth_must_be_before_today(): void - { - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Tijn', - 'last_name' => 'Kuiper', - 'email' => 'tijn@voorbeeld.nl', - - 'date_of_birth' => now()->addDay()->format('Y-m-d'), - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors('date_of_birth'); - } - - public function test_duplicate_email_rejected(): void - { - Mail::fake(); - - $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Anna', - 'last_name' => 'Smit', - 'email' => 'anna@voorbeeld.nl', - - ]); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Anna', - 'last_name' => 'Smit', - 'email' => 'anna@voorbeeld.nl', - - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors('email'); - } - - public function test_rejected_person_can_reregister(): void - { - Mail::fake(); - - 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", [ - 'first_name' => 'Herkan', - 'last_name' => '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", [ - 'first_name' => 'Test', - 'last_name' => '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", [ - 'first_name' => 'Bas', - 'last_name' => '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([ - 'first_name' => 'Lisa', - 'last_name' => '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([ - 'first_name' => 'Mark', - 'last_name' => '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([ - 'first_name' => 'Eva', - 'last_name' => '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_with_empty_token_returns_422(): void - { - $response = $this->postJson('/api/v1/portal/token-auth', [ - 'token' => '', - ]); - - $response->assertStatus(422); - } - - // ─── Portal Me ────────────────────────────────────────────────────── - - public function test_authenticated_user_gets_person(): void - { - $user = User::factory()->create(['first_name' => 'Karin', 'last_name' => '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(['first_name' => 'Tom', 'last_name' => '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(['first_name' => 'Sanne', 'last_name' => '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); - } - - // ─── Dynamic Field Values ────────────────────────────────────────── - - public function test_volunteer_can_register_with_field_values(): void - { - Mail::fake(); - - $selectField = RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $this->event->id, - 'sort_order' => 0, - ]); - - $textField = RegistrationFormField::factory()->textareaField()->create([ - 'event_id' => $this->event->id, - 'sort_order' => 1, - ]); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Noor', - 'last_name' => 'Janssen', - 'email' => 'noor@voorbeeld.nl', - - 'field_values' => [ - $selectField->slug => 'L', - $textField->slug => 'Ik ben een ervaren vrijwilliger', - ], - ]); - - $response->assertStatus(201); - - $person = Person::where('email', 'noor@voorbeeld.nl')->first(); - - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $person->id, - 'registration_form_field_id' => $selectField->id, - 'value' => 'L', - ]); - - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $person->id, - 'registration_form_field_id' => $textField->id, - 'value' => 'Ik ben een ervaren vrijwilliger', - ]); - } - - public function test_volunteer_can_register_with_section_preferences(): void - { - Mail::fake(); - - $section1 = FestivalSection::factory()->create([ - 'event_id' => $this->event->id, - 'name' => 'Hoofdpodium Bar', - ]); - - $section2 = FestivalSection::factory()->create([ - 'event_id' => $this->event->id, - 'name' => 'EHBO Post', - ]); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Rick', - 'last_name' => 'Peters', - 'email' => 'rick@voorbeeld.nl', - - 'section_preferences' => [ - ['festival_section_id' => $section1->id, 'priority' => 1], - ['festival_section_id' => $section2->id, 'priority' => 2], - ], - ]); - - $response->assertStatus(201); - - $person = Person::where('email', 'rick@voorbeeld.nl')->first(); - - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $person->id, - 'festival_section_id' => $section1->id, - 'priority' => 1, - ]); - - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $person->id, - 'festival_section_id' => $section2->id, - 'priority' => 2, - ]); - } - - public function test_volunteer_can_register_with_multiselect_field_values(): void - { - Mail::fake(); - - $multiselectField = RegistrationFormField::factory()->multiselectField()->create([ - 'event_id' => $this->event->id, - ]); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Femke', - 'last_name' => 'de Jong', - 'email' => 'femke@voorbeeld.nl', - - 'field_values' => [ - $multiselectField->slug => ['Vegetarisch', 'Glutenvrij'], - ], - ]); - - $response->assertStatus(201); - - $person = Person::where('email', 'femke@voorbeeld.nl')->first(); - - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $person->id, - 'registration_form_field_id' => $multiselectField->id, - ]); - - $value = $person->fieldValues() - ->where('registration_form_field_id', $multiselectField->id) - ->first(); - - $this->assertEquals(['Vegetarisch', 'Glutenvrij'], $value->selected_options); - } - - // ─── Passwordless Registration (account deferred to approval) ───────── - - public function test_registration_creates_person_without_user_account(): void - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Nieuwe', - 'last_name' => 'Vrijwilliger', - 'email' => 'nieuw@voorbeeld.nl', - ]); - - $response->assertStatus(201); - - $this->assertDatabaseHas('persons', [ - 'email' => 'nieuw@voorbeeld.nl', - 'event_id' => $this->event->id, - 'user_id' => null, - 'registration_source' => 'self', - ]); - - // No user account should be created at registration time - $this->assertDatabaseMissing('users', [ - 'email' => 'nieuw@voorbeeld.nl', - ]); - } - - public function test_registration_without_password_succeeds(): void - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Zonder', - 'last_name' => 'Wachtwoord', - 'email' => 'geenww@voorbeeld.nl', - ]); - - $response->assertStatus(201); - - $this->assertDatabaseHas('persons', [ - 'email' => 'geenww@voorbeeld.nl', - 'user_id' => null, - ]); - } - - public function test_registration_sends_confirmation_email(): void - { - Mail::fake(); - - $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Mail', - 'last_name' => 'Test', - 'email' => 'mailtest@voorbeeld.nl', - ]); - - Mail::assertQueued(RegistrationConfirmationMail::class, function ($mail) { - return $mail->hasTo('mailtest@voorbeeld.nl'); - }); - } - - public function test_email_is_always_stored_lowercase(): void - { - Mail::fake(); - - $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ - 'first_name' => 'Hoofdletter', - 'last_name' => 'Email', - 'email' => 'HOOFDLETTER@VOORBEELD.NL', - ]); - - $response->assertStatus(201); - - $this->assertDatabaseHas('persons', [ - 'email' => 'hoofdletter@voorbeeld.nl', - ]); - } - - public function test_password_not_required_for_authenticated_registration(): void - { - Mail::fake(); - - $user = User::factory()->create([ - 'first_name' => 'Auth', - 'last_name' => 'User', - 'email' => 'authuser@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); - } - - // ─── Registration Data Endpoint ───────────────────────────────────── - - public function test_registration_data_includes_registration_fields(): void - { - $field = RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $this->event->id, - 'is_portal_visible' => true, - 'is_admin_only' => false, - ]); - - $response = $this->getJson("/api/v1/public/events/{$this->event->slug}/registration-data"); - - $response->assertOk(); - $response->assertJsonPath('data.registration_fields.0.slug', $field->slug); - } - - public function test_registration_data_excludes_admin_only_fields(): void - { - RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $this->event->id, - 'is_portal_visible' => true, - 'is_admin_only' => true, - ]); - - $response = $this->getJson("/api/v1/public/events/{$this->event->slug}/registration-data"); - - $response->assertOk(); - $response->assertJsonCount(0, 'data.registration_fields'); - } - - public function test_registration_data_includes_form_toggles(): void - { - $response = $this->getJson("/api/v1/public/events/{$this->event->slug}/registration-data"); - - $response->assertOk(); - $response->assertJsonStructure([ - 'data' => [ - 'event' => [ - 'registration_show_section_preferences', - 'registration_show_availability', - ], - ], - ]); - } - - public function test_portal_me_includes_field_values_and_section_preferences(): void - { - $user = User::factory()->create(['first_name' => 'Lotte', 'last_name' => 'Vos']); - $this->organisation->users()->attach($user, ['role' => 'org_member']); - - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->volunteerCrowdType->id, - 'user_id' => $user->id, - 'email' => $user->email, - ]); - - $field = RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $this->event->id, - ]); - - \App\Models\PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field->id, - 'value' => 'M', - ]); - - \App\Models\PersonSectionPreference::create([ - 'person_id' => $person->id, - 'festival_section_id' => $this->section->id, - 'priority' => 1, - ]); - - Sanctum::actingAs($user); - - $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.field_values') - ->assertJsonPath('data.field_values.0.field_slug', $field->slug) - ->assertJsonPath('data.field_values.0.value', 'M') - ->assertJsonCount(1, 'data.section_preferences') - ->assertJsonPath('data.section_preferences.0.festival_section_id', $this->section->id) - ->assertJsonPath('data.section_preferences.0.priority', 1); - } -} diff --git a/api/tests/Feature/MigrationRollbackTest.php b/api/tests/Feature/MigrationRollbackTest.php index 0a73bd0c..787c3661 100644 --- a/api/tests/Feature/MigrationRollbackTest.php +++ b/api/tests/Feature/MigrationRollbackTest.php @@ -6,28 +6,25 @@ namespace Tests\Feature; use Illuminate\Foundation\Testing\WithoutMiddleware; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use PHPUnit\Framework\Attributes\Group; use Tests\TestCase; /** - * Hard-resets the test database via migrate:fresh, rolls back every - * form-builder migration in reverse, asserts the new tables are gone (and - * legacy tables remain — Phase 8 was deferred to S2 per S1 wrap-up), then - * re-applies and asserts the table list matches the post-fresh snapshot. + * Sanity-checks the Form Builder migration chain after S2a. * - * Slow because we exercise the real migrator against the real database. - * Tagged "slow" so CI can parallel-isolate or skip it where needed. + * Post-S2a: the legacy registration_* tables are DROPPED by a one-way + * migration (2026_04_20_100000_drop_remaining_legacy_registration_tables). + * Rolling back that migration throws by design — restoring the legacy + * tables from the new form_* structure would be lossy. + * + * Tagged "slow" because it exercises the real migrator. */ #[Group('slow')] final class MigrationRollbackTest extends TestCase { use WithoutMiddleware; - /** Migration steps added in S1 (Phase 3 + Phase 4). */ - private const S1_MIGRATION_STEPS = 14; - private const FORM_BUILDER_TABLES = [ 'user_profiles', 'form_schemas', @@ -44,71 +41,37 @@ final class MigrationRollbackTest extends TestCase 'form_webhook_deliveries', ]; - public function test_form_builder_migrations_are_fully_reversible(): void + private const LEGACY_TABLES = [ + 'registration_form_fields', + 'person_field_values', + 'registration_field_templates', + ]; + + public function test_form_builder_tables_present_after_migrate_fresh(): void { Artisan::call('migrate:fresh'); - $beforeTables = $this->tableList(); - // S1 leaves the legacy registration_* tables in place — Phase 8 - // was deferred to S2. Sanity-check that assumption is still true. - foreach (['registration_form_fields', 'person_field_values', 'registration_field_templates'] as $legacy) { - $this->assertTrue(Schema::hasTable($legacy), "legacy table {$legacy} should still exist after S1"); - } - - // Every form-builder table is present after fresh. foreach (self::FORM_BUILDER_TABLES as $table) { $this->assertTrue(Schema::hasTable($table), "{$table} should exist after migrate:fresh"); } - // Roll back exactly the S1 migration steps. - Artisan::call('migrate:rollback', ['--step' => self::S1_MIGRATION_STEPS]); - - // All form-builder tables should now be gone. - foreach (self::FORM_BUILDER_TABLES as $table) { - $this->assertFalse(Schema::hasTable($table), "{$table} should be dropped by rollback"); + foreach (self::LEGACY_TABLES as $legacy) { + $this->assertFalse( + Schema::hasTable($legacy), + "legacy table {$legacy} should NOT exist after S2a purge" + ); } - - // Legacy tables remain untouched by the rollback. - $this->assertTrue(Schema::hasTable('registration_form_fields')); - - // Re-apply: tables are recreated, table list matches snapshot. - Artisan::call('migrate'); - $afterTables = $this->tableList(); - sort($beforeTables); - sort($afterTables); - $this->assertSame($beforeTables, $afterTables); } - public function test_user_profiles_populate_migration_down_clears_backfilled_rows(): void + public function test_drop_legacy_tables_migration_is_one_way(): void { Artisan::call('migrate:fresh'); - // The populate migration ran during fresh. Assert it left rows for - // any users present at migrate time (test DB has none, so 0 is OK). - $populatedCount = DB::table('user_profiles')->count(); + // step=1 targets the most recent migration — the S2a drop — + // whose down() is a hard failure. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Legacy registration tables cannot be restored'); - // down() of the populate migration deletes all profiles. - Artisan::call('migrate:rollback', ['--step' => self::S1_MIGRATION_STEPS - 1]); - Artisan::call('migrate:rollback', ['--step' => 1]); // populate step - // Next rollback step now drops the table — handled by the other test. - - // Re-apply for clean state for subsequent tests. - Artisan::call('migrate'); - - // Sanity: counts can be compared before/after but tests are isolated - // per RefreshDatabase so we mainly assert no exceptions. - $this->assertSame($populatedCount, DB::table('user_profiles')->count()); - } - - /** - * @return array - */ - private function tableList(): array - { - return collect(Schema::getTables()) - ->pluck('name') - ->reject(fn (string $n) => str_starts_with($n, 'sqlite_') || $n === 'migrations') - ->values() - ->all(); + Artisan::call('migrate:rollback', ['--step' => 1]); } } diff --git a/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php b/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php deleted file mode 100644 index debd6cc5..00000000 --- a/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php +++ /dev/null @@ -1,367 +0,0 @@ -seed(RoleSeeder::class); - - $this->organisation = Organisation::factory()->create(); - $this->otherOrganisation = Organisation::factory()->create(); - - $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); - - $this->crowdType = CrowdType::factory()->create(['organisation_id' => $this->organisation->id]); - - $this->person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - ]); - - $this->orgAdmin = User::factory()->create(); - $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); - - $this->outsider = User::factory()->create(); - $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); - } - - public function test_upsert_text_value(): void - { - $field = RegistrationFormField::factory()->textField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => 'Jan Jansen', - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $this->person->id, - 'registration_form_field_id' => $field->id, - 'value' => 'Jan Jansen', - ]); - } - - public function test_upsert_select_value(): void - { - $field = RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => 'M', - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $this->person->id, - 'registration_form_field_id' => $field->id, - 'value' => 'M', - ]); - } - - public function test_upsert_select_invalid_option_rejected(): void - { - $field = RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => 'XXXXL', - ], - ]); - - $response->assertUnprocessable(); - } - - public function test_upsert_multiselect_value(): void - { - $field = RegistrationFormField::factory()->multiselectField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => ['Vegetarisch', 'Glutenvrij'], - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $this->person->id, - 'registration_form_field_id' => $field->id, - ]); - - $storedValue = \App\Models\PersonFieldValue::where('person_id', $this->person->id) - ->where('registration_form_field_id', $field->id) - ->first(); - - $this->assertEquals(['Vegetarisch', 'Glutenvrij'], $storedValue->selected_options); - } - - public function test_upsert_boolean_value(): void - { - $field = RegistrationFormField::factory()->booleanField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => true, - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $this->person->id, - 'registration_form_field_id' => $field->id, - 'value' => '1', - ]); - } - - public function test_upsert_required_field_rejects_empty(): void - { - $field = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'label' => 'Verplicht veld', - 'slug' => 'verplicht-veld', - 'field_type' => RegistrationFieldType::TEXT, - 'is_required' => true, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => null, - ], - ]); - - $response->assertUnprocessable(); - } - - public function test_upsert_tag_picker_with_valid_tags(): void - { - $tag1 = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'name' => 'Tapper', - ]); - $tag2 = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'name' => 'EHBO', - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => [$tag1->id, $tag2->id], - ], - ]); - - $response->assertOk(); - - $storedValue = \App\Models\PersonFieldValue::where('person_id', $this->person->id) - ->where('registration_form_field_id', $field->id) - ->first(); - - $this->assertContains($tag1->id, $storedValue->selected_options); - $this->assertContains($tag2->id, $storedValue->selected_options); - } - - public function test_upsert_tag_picker_with_invalid_tag_rejected(): void - { - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => ['01JFAKE00000000000000TAGID'], - ], - ]); - - $response->assertUnprocessable(); - } - - public function test_tag_sync_triggered_when_person_has_user_id(): void - { - $linkedUser = User::factory()->create(); - - $this->person->user_id = $linkedUser->id; - $this->person->save(); - - $tag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'name' => 'Tapper', - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => [$tag->id], - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('user_organisation_tags', [ - 'user_id' => $linkedUser->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $tag->id, - 'source' => 'self_reported', - ]); - } - - public function test_tag_sync_not_triggered_when_no_user_id(): void - { - $this->assertNull($this->person->user_id); - - $tag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'name' => 'Tapper', - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values", [ - 'values' => [ - $field->slug => [$tag->id], - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseMissing('user_organisation_tags', [ - 'person_tag_id' => $tag->id, - 'source' => 'self_reported', - ]); - } - - public function test_index_returns_person_values(): void - { - $field = RegistrationFormField::factory()->textField()->create([ - 'event_id' => $this->event->id, - ]); - - \App\Models\PersonFieldValue::create([ - 'person_id' => $this->person->id, - 'registration_form_field_id' => $field->id, - 'value' => 'Test waarde', - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values"); - - $response->assertOk(); - $this->assertCount(1, $response->json('data')); - } - - public function test_values_persist_after_field_deleted(): void - { - $field = RegistrationFormField::factory()->textField()->create([ - 'event_id' => $this->event->id, - ]); - - \App\Models\PersonFieldValue::create([ - 'person_id' => $this->person->id, - 'registration_form_field_id' => $field->id, - 'value' => 'Persisted value', - ]); - - $fieldId = $field->id; - - Sanctum::actingAs($this->orgAdmin); - - $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/{$field->id}") - ->assertNoContent(); - - // Value persists with FK set to null (nullOnDelete) - $this->assertDatabaseHas('person_field_values', [ - 'person_id' => $this->person->id, - 'registration_form_field_id' => null, - 'value' => 'Persisted value', - ]); - } - - public function test_cross_org_returns_403(): void - { - Sanctum::actingAs($this->outsider); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values"); - - $response->assertForbidden(); - } - - public function test_unauthenticated_returns_401(): void - { - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/field-values"); - - $response->assertUnauthorized(); - } -} diff --git a/api/tests/Feature/PersonSectionPreference/PersonSectionPreferenceTest.php b/api/tests/Feature/PersonSectionPreference/PersonSectionPreferenceTest.php deleted file mode 100644 index 61dfacd1..00000000 --- a/api/tests/Feature/PersonSectionPreference/PersonSectionPreferenceTest.php +++ /dev/null @@ -1,279 +0,0 @@ -seed(RoleSeeder::class); - - $this->organisation = Organisation::factory()->create(); - $this->otherOrganisation = Organisation::factory()->create(); - - $this->festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $this->subEvent = Event::factory()->subEvent($this->festival)->create(); - - $this->sectionA = FestivalSection::factory()->create([ - 'event_id' => $this->subEvent->id, - 'name' => 'Horeca', - ]); - $this->sectionB = FestivalSection::factory()->create([ - 'event_id' => $this->subEvent->id, - 'name' => 'Backstage', - ]); - $this->sectionC = FestivalSection::factory()->create([ - 'event_id' => $this->subEvent->id, - 'name' => 'Techniek', - ]); - - $this->crossEventSection = FestivalSection::factory()->crossEvent()->create([ - 'event_id' => $this->festival->id, - 'name' => 'Security', - ]); - - $crowdType = CrowdType::factory()->create(['organisation_id' => $this->organisation->id]); - - $this->person = Person::factory()->create([ - 'event_id' => $this->subEvent->id, - 'crowd_type_id' => $crowdType->id, - ]); - - $this->orgAdmin = User::factory()->create(); - $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); - - $this->outsider = User::factory()->create(); - $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); - } - - public function test_replace_preferences_happy_path(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->sectionA->id, 'priority' => 1], - ['festival_section_id' => $this->sectionB->id, 'priority' => 2], - ['festival_section_id' => $this->sectionC->id, 'priority' => 3], - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->sectionA->id, - 'priority' => 1, - ]); - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->sectionB->id, - 'priority' => 2, - ]); - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->sectionC->id, - 'priority' => 3, - ]); - } - - public function test_replace_deletes_old_and_creates_new(): void - { - Sanctum::actingAs($this->orgAdmin); - - // First: set 2 preferences - $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->sectionA->id, 'priority' => 1], - ['festival_section_id' => $this->sectionB->id, 'priority' => 2], - ], - ])->assertOk(); - - $this->assertEquals(2, PersonSectionPreference::where('person_id', $this->person->id)->count()); - - // Second: replace with 1 different preference - $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->sectionC->id, 'priority' => 1], - ], - ])->assertOk(); - - $this->assertEquals(1, PersonSectionPreference::where('person_id', $this->person->id)->count()); - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->sectionC->id, - 'priority' => 1, - ]); - $this->assertDatabaseMissing('person_section_preferences', [ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->sectionA->id, - ]); - } - - public function test_priority_must_be_unique(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->sectionA->id, 'priority' => 1], - ['festival_section_id' => $this->sectionB->id, 'priority' => 1], - ], - ]); - - $response->assertUnprocessable(); - } - - public function test_priority_range_1_to_5(): void - { - Sanctum::actingAs($this->orgAdmin); - - // Priority 0 should fail - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->sectionA->id, 'priority' => 0], - ], - ]); - - $response->assertUnprocessable(); - - // Priority 6 should fail - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->sectionA->id, 'priority' => 6], - ], - ]); - - $response->assertUnprocessable(); - } - - public function test_max_5_preferences(): void - { - // Create 3 more sections to have 6 total - $sectionD = FestivalSection::factory()->create(['event_id' => $this->subEvent->id, 'name' => 'Catering']); - $sectionE = FestivalSection::factory()->create(['event_id' => $this->subEvent->id, 'name' => 'Logistiek']); - $sectionF = FestivalSection::factory()->create(['event_id' => $this->subEvent->id, 'name' => 'Opbouw']); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->sectionA->id, 'priority' => 1], - ['festival_section_id' => $this->sectionB->id, 'priority' => 2], - ['festival_section_id' => $this->sectionC->id, 'priority' => 3], - ['festival_section_id' => $sectionD->id, 'priority' => 4], - ['festival_section_id' => $sectionE->id, 'priority' => 5], - ['festival_section_id' => $sectionF->id, 'priority' => 6], - ], - ]); - - $response->assertUnprocessable(); - } - - public function test_section_must_belong_to_event(): void - { - $otherEvent = Event::factory()->create(['organisation_id' => $this->organisation->id]); - $otherSection = FestivalSection::factory()->create([ - 'event_id' => $otherEvent->id, - 'name' => 'Andere sectie', - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $otherSection->id, 'priority' => 1], - ], - ]); - - $response->assertUnprocessable(); - } - - public function test_cross_event_section_from_parent_accepted(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences", [ - 'preferences' => [ - ['festival_section_id' => $this->crossEventSection->id, 'priority' => 1], - ], - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('person_section_preferences', [ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->crossEventSection->id, - 'priority' => 1, - ]); - } - - public function test_index_returns_preferences(): void - { - PersonSectionPreference::create([ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->sectionA->id, - 'priority' => 1, - ]); - PersonSectionPreference::create([ - 'person_id' => $this->person->id, - 'festival_section_id' => $this->sectionB->id, - 'priority' => 2, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences"); - - $response->assertOk(); - $this->assertCount(2, $response->json('data')); - } - - public function test_cross_org_returns_403(): void - { - Sanctum::actingAs($this->outsider); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences"); - - $response->assertForbidden(); - } - - public function test_unauthenticated_returns_401(): void - { - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences"); - - $response->assertUnauthorized(); - } -} diff --git a/api/tests/Feature/RegistrationFieldTemplate/RegistrationFieldTemplateTest.php b/api/tests/Feature/RegistrationFieldTemplate/RegistrationFieldTemplateTest.php deleted file mode 100644 index 1560973f..00000000 --- a/api/tests/Feature/RegistrationFieldTemplate/RegistrationFieldTemplateTest.php +++ /dev/null @@ -1,202 +0,0 @@ -seed(RoleSeeder::class); - - $this->organisation = Organisation::factory()->create(); - $this->otherOrganisation = Organisation::factory()->create(); - - $this->orgAdmin = User::factory()->create(); - $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); - - $this->outsider = User::factory()->create(); - $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); - } - - public function test_index_returns_all_templates(): void - { - RegistrationFieldTemplate::factory()->count(3)->create([ - 'organisation_id' => $this->organisation->id, - ]); - RegistrationFieldTemplate::factory()->inactive()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates"); - - $response->assertOk(); - $this->assertCount(4, $response->json('data')); - } - - public function test_store_creates_template(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates", [ - 'label' => 'Allergieën', - 'field_type' => 'text', - ]); - - $response->assertCreated(); - - $this->assertDatabaseHas('registration_field_templates', [ - 'organisation_id' => $this->organisation->id, - 'label' => 'Allergieën', - 'slug' => 'allergieen', - ]); - } - - public function test_store_select_requires_options(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates", [ - 'label' => 'Voorkeur', - 'field_type' => 'select', - ]); - - $response->assertUnprocessable() - ->assertJsonValidationErrors('options'); - } - - public function test_update_template(): void - { - $template = RegistrationFieldTemplate::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'label' => 'Oude label', - 'slug' => 'oude-label', - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates/{$template->id}", [ - 'label' => 'Nieuwe label', - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('registration_field_templates', [ - 'id' => $template->id, - 'label' => 'Nieuwe label', - 'slug' => 'nieuwe-label', - ]); - } - - public function test_destroy_org_template_deletes(): void - { - $template = RegistrationFieldTemplate::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'is_system' => false, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates/{$template->id}"); - - $response->assertNoContent(); - $this->assertDatabaseMissing('registration_field_templates', [ - 'id' => $template->id, - ]); - } - - public function test_destroy_system_template_deactivates(): void - { - $template = RegistrationFieldTemplate::factory()->system()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates/{$template->id}"); - - $response->assertNoContent(); - $this->assertDatabaseHas('registration_field_templates', [ - 'id' => $template->id, - 'is_active' => false, - ]); - } - - public function test_system_templates_seeded_on_org_creation(): void - { - RegistrationFieldTemplateService::seedSystemTemplates($this->organisation); - - $count = RegistrationFieldTemplate::where('organisation_id', $this->organisation->id) - ->where('is_system', true) - ->count(); - - $this->assertEquals(16, $count); - } - - public function test_create_field_from_template(): void - { - $template = RegistrationFieldTemplate::factory()->create([ - 'organisation_id' => $this->organisation->id, - 'label' => 'Shirtmaat', - 'slug' => 'shirtmaat', - 'field_type' => 'select', - 'options' => ['XS', 'S', 'M', 'L', 'XL'], - ]); - - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}/registration-fields/from-template", [ - 'template_id' => $template->id, - ]); - - $response->assertCreated(); - - $this->assertDatabaseHas('registration_form_fields', [ - 'event_id' => $event->id, - 'label' => 'Shirtmaat', - 'field_type' => 'select', - ]); - } - - public function test_cross_org_returns_403(): void - { - Sanctum::actingAs($this->outsider); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates"); - - $response->assertForbidden(); - } - - public function test_unauthenticated_returns_401(): void - { - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/registration-field-templates"); - - $response->assertUnauthorized(); - } -} diff --git a/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php b/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php deleted file mode 100644 index c35965ec..00000000 --- a/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php +++ /dev/null @@ -1,549 +0,0 @@ -seed(RoleSeeder::class); - - $this->organisation = Organisation::factory()->create(); - $this->otherOrganisation = Organisation::factory()->create(); - - $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); - - $this->orgAdmin = User::factory()->create(); - $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); - - $this->outsider = User::factory()->create(); - $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); - } - - public function test_index_returns_fields_ordered_by_sort_order(): void - { - RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'label' => 'Third', - 'slug' => 'third', - 'sort_order' => 3, - ]); - RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'label' => 'First', - 'slug' => 'first', - 'sort_order' => 1, - ]); - RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'label' => 'Second', - 'slug' => 'second', - 'sort_order' => 2, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields"); - - $response->assertOk(); - $this->assertCount(3, $response->json('data')); - $this->assertEquals('First', $response->json('data.0.label')); - $this->assertEquals('Second', $response->json('data.1.label')); - $this->assertEquals('Third', $response->json('data.2.label')); - } - - public function test_store_creates_field_with_auto_slug(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Shirtmaat', - 'field_type' => 'select', - 'options' => ['XS', 'S', 'M', 'L', 'XL'], - ]); - - $response->assertCreated() - ->assertJsonPath('data.slug', 'shirtmaat') - ->assertJsonPath('data.label', 'Shirtmaat'); - - $this->assertDatabaseHas('registration_form_fields', [ - 'event_id' => $this->event->id, - 'slug' => 'shirtmaat', - ]); - } - - public function test_store_select_field_requires_options(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Shirtmaat', - 'field_type' => 'select', - ]); - - $response->assertUnprocessable() - ->assertJsonValidationErrors('options'); - } - - public function test_store_text_field_rejects_options(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Naam', - 'field_type' => 'text', - 'options' => ['A', 'B'], - ]); - - $response->assertUnprocessable() - ->assertJsonValidationErrors('options'); - } - - public function test_store_tag_picker_accepts_tag_categories(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Vaardigheden', - 'field_type' => 'tag_picker', - 'tag_categories' => ['Vaardigheid', 'Horeca'], - ]); - - $response->assertCreated() - ->assertJsonPath('data.tag_categories', ['Vaardigheid', 'Horeca']); - } - - public function test_store_text_field_rejects_tag_categories(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Naam', - 'field_type' => 'text', - 'tag_categories' => ['Vaardigheid'], - ]); - - $response->assertUnprocessable() - ->assertJsonValidationErrors('tag_categories'); - } - - public function test_slug_uniqueness_per_event(): void - { - RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'label' => 'Shirtmaat', - 'slug' => 'shirtmaat', - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Shirtmaat', - 'field_type' => 'text', - ]); - - $response->assertCreated() - ->assertJsonPath('data.slug', 'shirtmaat-2'); - } - - public function test_same_slug_allowed_on_different_events(): void - { - $otherEvent = Event::factory()->create(['organisation_id' => $this->organisation->id]); - - RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'label' => 'Shirtmaat', - 'slug' => 'shirtmaat', - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$otherEvent->id}/registration-fields", [ - 'label' => 'Shirtmaat', - 'field_type' => 'text', - ]); - - $response->assertCreated() - ->assertJsonPath('data.slug', 'shirtmaat'); - } - - public function test_update_field(): void - { - $field = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'label' => 'Old Label', - 'slug' => 'old-label', - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/{$field->id}", [ - 'label' => 'New Label', - ]); - - $response->assertOk() - ->assertJsonPath('data.label', 'New Label') - ->assertJsonPath('data.slug', 'new-label'); - } - - public function test_cannot_change_field_type(): void - { - $field = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'field_type' => RegistrationFieldType::TEXT, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/{$field->id}", [ - 'field_type' => 'select', - ]); - - // field_type is not in UpdateRegistrationFormFieldRequest rules, so it's ignored - $response->assertOk(); - $this->assertDatabaseHas('registration_form_fields', [ - 'id' => $field->id, - 'field_type' => 'text', - ]); - } - - public function test_destroy_deletes_field(): void - { - $field = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/{$field->id}"); - - $response->assertNoContent(); - $this->assertDatabaseMissing('registration_form_fields', [ - 'id' => $field->id, - ]); - } - - public function test_reorder_fields(): void - { - $fieldA = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'sort_order' => 0, - ]); - $fieldB = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'sort_order' => 1, - ]); - $fieldC = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'sort_order' => 2, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/reorder", [ - 'ids' => [$fieldC->id, $fieldA->id, $fieldB->id], - ]); - - $response->assertNoContent(); - - $this->assertDatabaseHas('registration_form_fields', ['id' => $fieldC->id, 'sort_order' => 0]); - $this->assertDatabaseHas('registration_form_fields', ['id' => $fieldA->id, 'sort_order' => 1]); - $this->assertDatabaseHas('registration_form_fields', ['id' => $fieldB->id, 'sort_order' => 2]); - } - - public function test_import_from_event(): void - { - $sourceEvent = Event::factory()->create(['organisation_id' => $this->organisation->id]); - - RegistrationFormField::factory()->create([ - 'event_id' => $sourceEvent->id, - 'label' => 'Shirtmaat', - 'slug' => 'shirtmaat', - 'field_type' => RegistrationFieldType::SELECT, - 'options' => ['S', 'M', 'L'], - ]); - RegistrationFormField::factory()->create([ - 'event_id' => $sourceEvent->id, - 'label' => 'Opmerkingen', - 'slug' => 'opmerkingen', - 'field_type' => RegistrationFieldType::TEXTAREA, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/import-from-event", [ - 'source_event_id' => $sourceEvent->id, - ]); - - $response->assertOk(); - - $this->assertDatabaseHas('registration_form_fields', [ - 'event_id' => $this->event->id, - 'slug' => 'shirtmaat', - ]); - $this->assertDatabaseHas('registration_form_fields', [ - 'event_id' => $this->event->id, - 'slug' => 'opmerkingen', - ]); - } - - public function test_import_from_different_org_rejected(): void - { - $otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/import-from-event", [ - 'source_event_id' => $otherEvent->id, - ]); - - $response->assertUnprocessable() - ->assertJsonValidationErrors('source_event_id'); - } - - public function test_from_template_creates_copy(): void - { - $template = RegistrationFieldTemplate::factory()->selectField()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/from-template", [ - 'template_id' => $template->id, - ]); - - $response->assertCreated() - ->assertJsonPath('data.label', $template->label) - ->assertJsonPath('data.field_type', $template->field_type->value); - - $this->assertDatabaseHas('registration_form_fields', [ - 'event_id' => $this->event->id, - 'label' => $template->label, - ]); - } - - public function test_cross_org_returns_403(): void - { - Sanctum::actingAs($this->outsider); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields"); - - $response->assertForbidden(); - } - - public function test_unauthenticated_returns_401(): void - { - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields"); - - $response->assertUnauthorized(); - } - - public function test_store_field_with_display_width(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Noodcontact naam', - 'field_type' => 'text', - 'display_width' => 'half', - ]); - - $response->assertCreated() - ->assertJsonPath('data.display_width', 'half'); - - $this->assertDatabaseHas('registration_form_fields', [ - 'event_id' => $this->event->id, - 'slug' => 'noodcontact-naam', - 'display_width' => 'half', - ]); - } - - public function test_store_field_defaults_display_width_by_type(): void - { - Sanctum::actingAs($this->orgAdmin); - - // Text fields default to 'half' - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Korte tekst', - 'field_type' => 'text', - ]); - - $response->assertCreated() - ->assertJsonPath('data.display_width', 'half'); - - // Textarea fields default to 'full' - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Opmerkingen', - 'field_type' => 'textarea', - ]); - - $response->assertCreated() - ->assertJsonPath('data.display_width', 'full'); - } - - public function test_update_field_display_width(): void - { - $field = RegistrationFormField::factory()->create([ - 'event_id' => $this->event->id, - 'display_width' => 'full', - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/{$field->id}", [ - 'display_width' => 'half', - ]); - - $response->assertOk() - ->assertJsonPath('data.display_width', 'half'); - } - - public function test_store_field_with_option_descriptions(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Vergoeding', - 'field_type' => 'radio', - 'options' => [ - ['label' => 'Pro Deo', 'description' => 'Geen vergoeding'], - ['label' => 'Entreeticket', 'description' => 'Gratis ticket'], - ], - ]); - - $response->assertCreated() - ->assertJsonPath('data.normalized_options.0.label', 'Pro Deo') - ->assertJsonPath('data.normalized_options.0.description', 'Geen vergoeding') - ->assertJsonPath('data.normalized_options.1.label', 'Entreeticket') - ->assertJsonPath('data.normalized_options.1.description', 'Gratis ticket'); - } - - public function test_options_backwards_compatible_with_string_array(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Shirtmaat', - 'field_type' => 'select', - 'options' => ['XS', 'S', 'M', 'L'], - ]); - - $response->assertCreated() - ->assertJsonPath('data.normalized_options.0.label', 'XS') - ->assertJsonPath('data.normalized_options.0.description', null) - ->assertJsonPath('data.normalized_options.3.label', 'L'); - } - - public function test_normalized_options_converts_strings_to_objects(): void - { - $field = RegistrationFormField::factory()->selectField()->create([ - 'event_id' => $this->event->id, - ]); - - $this->assertNotNull($field->normalized_options); - $this->assertIsArray($field->normalized_options); - - // Each option should be an array with label and description keys - foreach ($field->normalized_options as $option) { - $this->assertArrayHasKey('label', $option); - $this->assertArrayHasKey('description', $option); - $this->assertIsString($option['label']); - } - } - - public function test_index_returns_display_width_and_normalized_options(): void - { - RegistrationFormField::factory()->radioField()->create([ - 'event_id' => $this->event->id, - 'sort_order' => 0, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields"); - - $response->assertOk(); - $field = $response->json('data.0'); - $this->assertArrayHasKey('display_width', $field); - $this->assertArrayHasKey('normalized_options', $field); - $this->assertEquals('full', $field['display_width']); - $this->assertNotNull($field['normalized_options']); - } - - public function test_store_heading_field_without_options(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Persoonlijke voorkeuren', - 'field_type' => 'heading', - 'help_text' => 'Vertel ons wat we over jou moeten weten', - ]); - - $response->assertCreated() - ->assertJsonPath('data.field_type', 'heading') - ->assertJsonPath('data.label', 'Persoonlijke voorkeuren') - ->assertJsonPath('data.help_text', 'Vertel ons wat we over jou moeten weten') - ->assertJsonPath('data.display_width', 'full'); - } - - public function test_heading_field_skips_options_validation(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Sectiekop', - 'field_type' => 'heading', - ]); - - $response->assertCreated(); - } - - public function test_heading_field_display_width_defaults_to_full(): void - { - Sanctum::actingAs($this->orgAdmin); - - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ - 'label' => 'Noodcontact', - 'field_type' => 'heading', - ]); - - $response->assertCreated() - ->assertJsonPath('data.display_width', 'full'); - } - - public function test_seeder_creates_heading_fields(): void - { - \App\Services\RegistrationFieldTemplateService::seedSystemTemplates($this->organisation); - - $headingCount = \App\Models\RegistrationFieldTemplate::where('organisation_id', $this->organisation->id) - ->where('is_system', true) - ->where('field_type', 'heading') - ->count(); - - $this->assertEquals(5, $headingCount); - } -} diff --git a/api/tests/Feature/Security/MultiTenancyIsolationTest.php b/api/tests/Feature/Security/MultiTenancyIsolationTest.php index adacd748..bcecd33a 100644 --- a/api/tests/Feature/Security/MultiTenancyIsolationTest.php +++ b/api/tests/Feature/Security/MultiTenancyIsolationTest.php @@ -321,17 +321,6 @@ final class MultiTenancyIsolationTest extends TestCase $response->assertForbidden(); } - // --- Cross-tenant registration field access --- - - public function test_cannot_access_other_org_registration_fields(): void - { - Sanctum::actingAs($this->adminB); - - $response = $this->getJson("/api/v1/organisations/{$this->orgA->id}/events/{$this->eventA->id}/registration-fields"); - - $response->assertForbidden(); - } - // --- Cross-tenant shift assignment listing --- public function test_cannot_list_other_org_shift_assignments(): void diff --git a/api/tests/Feature/TagSync/TagSyncServiceTest.php b/api/tests/Feature/TagSync/TagSyncServiceTest.php deleted file mode 100644 index ca8efe34..00000000 --- a/api/tests/Feature/TagSync/TagSyncServiceTest.php +++ /dev/null @@ -1,335 +0,0 @@ -seed(RoleSeeder::class); - - $this->organisation = Organisation::factory()->create(); - $this->event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ - 'organisation_id' => $this->organisation->id, - ]); - } - - public function test_sync_creates_self_reported_tags_when_person_has_user_id(): void - { - $user = User::factory()->create(); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => $user->id, - ]); - - $tag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field->id, - 'selected_options' => [$tag->id], - ]); - - (new TagSyncService())->syncFromRegistration($person); - - $this->assertDatabaseHas('user_organisation_tags', [ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $tag->id, - 'source' => 'self_reported', - ]); - } - - public function test_sync_skips_when_person_has_no_user_id(): void - { - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => null, - ]); - - $tag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field->id, - 'selected_options' => [$tag->id], - ]); - - (new TagSyncService())->syncFromRegistration($person); - - $this->assertDatabaseCount('user_organisation_tags', 0); - } - - public function test_sync_does_not_touch_organiser_assigned_tags(): void - { - $user = User::factory()->create(); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => $user->id, - ]); - - $organisedTag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - UserOrganisationTag::create([ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $organisedTag->id, - 'source' => 'organiser_assigned', - 'assigned_at' => now(), - ]); - - $selfTag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field->id, - 'selected_options' => [$selfTag->id], - ]); - - (new TagSyncService())->syncFromRegistration($person); - - $this->assertDatabaseHas('user_organisation_tags', [ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $organisedTag->id, - 'source' => 'organiser_assigned', - ]); - - $this->assertDatabaseHas('user_organisation_tags', [ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $selfTag->id, - 'source' => 'self_reported', - ]); - } - - public function test_sync_removes_self_reported_tags_no_longer_in_selection(): void - { - $user = User::factory()->create(); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => $user->id, - ]); - - $tag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - UserOrganisationTag::create([ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $tag->id, - 'source' => 'self_reported', - 'assigned_at' => now(), - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field->id, - 'selected_options' => [], - ]); - - (new TagSyncService())->syncFromRegistration($person); - - $this->assertDatabaseMissing('user_organisation_tags', [ - 'user_id' => $user->id, - 'person_tag_id' => $tag->id, - 'source' => 'self_reported', - ]); - } - - public function test_sync_is_idempotent(): void - { - $user = User::factory()->create(); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => $user->id, - ]); - - $tag = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field->id, - 'selected_options' => [$tag->id], - ]); - - $service = new TagSyncService(); - $service->syncFromRegistration($person); - $service->syncFromRegistration($person); - - $count = UserOrganisationTag::where('user_id', $user->id) - ->where('organisation_id', $this->organisation->id) - ->where('person_tag_id', $tag->id) - ->where('source', 'self_reported') - ->count(); - - $this->assertEquals(1, $count); - } - - public function test_sync_handles_empty_tag_selection(): void - { - $user = User::factory()->create(); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => $user->id, - ]); - - $tagA = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - $tagB = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - UserOrganisationTag::create([ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $tagA->id, - 'source' => 'self_reported', - 'assigned_at' => now(), - ]); - UserOrganisationTag::create([ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $tagB->id, - 'source' => 'self_reported', - 'assigned_at' => now(), - ]); - - $field = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - ]); - - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field->id, - 'selected_options' => [], - ]); - - (new TagSyncService())->syncFromRegistration($person); - - $count = UserOrganisationTag::where('user_id', $user->id) - ->where('source', 'self_reported') - ->count(); - - $this->assertEquals(0, $count); - } - - public function test_sync_works_across_multiple_tag_picker_fields(): void - { - $user = User::factory()->create(); - $person = Person::factory()->create([ - 'event_id' => $this->event->id, - 'crowd_type_id' => $this->crowdType->id, - 'user_id' => $user->id, - ]); - - $tagA = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - $tagB = PersonTag::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $field1 = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - 'label' => 'Vaardigheden', - 'slug' => 'vaardigheden', - ]); - $field2 = RegistrationFormField::factory()->tagPickerField()->create([ - 'event_id' => $this->event->id, - 'label' => 'Talen', - 'slug' => 'talen', - ]); - - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field1->id, - 'selected_options' => [$tagA->id], - ]); - PersonFieldValue::create([ - 'person_id' => $person->id, - 'registration_form_field_id' => $field2->id, - 'selected_options' => [$tagB->id], - ]); - - (new TagSyncService())->syncFromRegistration($person); - - $this->assertDatabaseHas('user_organisation_tags', [ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $tagA->id, - 'source' => 'self_reported', - ]); - $this->assertDatabaseHas('user_organisation_tags', [ - 'user_id' => $user->id, - 'organisation_id' => $this->organisation->id, - 'person_tag_id' => $tagB->id, - 'source' => 'self_reported', - ]); - } -} diff --git a/dev-docs/API.md b/dev-docs/API.md index faf66bcf..f5060ad1 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -605,30 +605,6 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al - `GET /organisations/{org}/events/{event}/persons?tag={person_tag_id}` — filter persons by single tag - `GET /organisations/{org}/events/{event}/persons?tags=ulid1,ulid2` — filter persons by multiple tags (AND logic: must have all) -## 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`. 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 - -```json -{ - "data": { - "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", "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 }] - } -} -``` - -### Error Responses - -- `404` — Event not found or not accepting registrations - -## Volunteer Registration - -- `POST /events/{event}/volunteer-register` — public, auth-aware (optional Sanctum). Registers a volunteer for an event. Resolves sub-events to the parent festival. Accepts name, email, phone, tshirt_size, motivation, section_preferences, availabilities. Authenticated users have their name/email taken from the auth token. Returns `PersonResource` (201 on new, 200 on re-registration of rejected person). - ## Portal - `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid. @@ -675,99 +651,27 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al } ``` -## Registration Field Templates (Organisation Settings) +## Form Builder -- `GET /organisations/{org}/registration-field-templates` — list active templates (ordered) -- `POST /organisations/{org}/registration-field-templates` — create template -- `PUT /organisations/{org}/registration-field-templates/{template}` — update template -- `DELETE /organisations/{org}/registration-field-templates/{template}` — delete (org-created) or deactivate (system) - -> Templates: organisation-level reusable field definitions. System templates -> are seeded on org creation. Org-admins can customize and add their own. - -## Registration Form Fields (Event Settings) - -- `GET /organisations/{org}/events/{event}/registration-fields` — list all fields (ordered by sort_order) -- `POST /organisations/{org}/events/{event}/registration-fields` — create field (manually or from template) -- `POST /organisations/{org}/events/{event}/registration-fields/from-template` — create field from template -- `PUT /organisations/{org}/events/{event}/registration-fields/{field}` — update field -- `DELETE /organisations/{org}/events/{event}/registration-fields/{field}` — delete field definition (answers preserved) -- `POST /organisations/{org}/events/{event}/registration-fields/reorder` — bulk reorder -- `POST /organisations/{org}/events/{event}/registration-fields/import-from-event` — copy fields from another event - -### From-Template Body - -```json -{ "template_id": "ulid" } -``` - -Creates a COPY of the template as an event field. The copy is independent — changes don't propagate back to the template. - -### Import Body - -```json -{ "source_event_id": "ulid" } -``` - -Copies all `registration_form_fields` from the source event. Source must belong to the same organisation. Existing fields on the target event are kept. - -### Response Fields - -Each registration form field response includes: - -- `options` — raw stored format (string array or object array, for backwards compatibility) -- `normalized_options` — always `[{label, description}]` format (null when field has no options). Descriptions are null when not set. Use this for rendering. -- `display_width` — `"full"` or `"half"`, controls form layout column width. Auto-set based on field type when not explicitly provided. - -### Tag Picker Fields - -For `tag_picker` fields: the API response includes `available_tags` array (from `person_tags`, filtered by `tag_category` if set) so the frontend knows which tags to render as options. - -## Person Field Values - -- `GET /organisations/{org}/events/{event}/persons/{person}/field-values` — all answers for a person -- `PUT /organisations/{org}/events/{event}/persons/{person}/field-values` — bulk upsert answers - -### Bulk Upsert Body - -```json -{ - "values": { - "field_slug": "value_or_array", - "shirtmaat": "L", - "dieetwensen": ["Vegetarisch", "Glutenvrij"], - "certificaten": ["01JXYZ...", "01JABC..."] - } -} -``` - -Replaces all field values for this person in one request. Used by both the registration form and the organiser backend. For `tag_picker` fields: values are arrays of `person_tag_id` ULIDs. If person has a `user_id`, tag sync is triggered automatically. - -## Person Section Preferences - -- `GET /organisations/{org}/events/{event}/persons/{person}/section-preferences` — list preferences -- `PUT /organisations/{org}/events/{event}/persons/{person}/section-preferences` — replace all preferences - -### Replace Body - -```json -{ - "preferences": [ - { "festival_section_id": "01JXYZ...", "priority": 1 }, - { "festival_section_id": "01JABC...", "priority": 2 }, - { "festival_section_id": "01JDEF...", "priority": 3 } - ] -} -``` +The legacy registration-form-fields / person-field-values / +registration-field-templates / person-section-preferences endpoints were +purged in S2a. Their replacements (new Form Builder CRUD, public form +submission endpoints, tag-sync listener) land in S2b+. This section will +be filled in then — see `/dev-docs/ARCH-FORM-BUILDER.md` §4 for the +target schema and §31 for integration contracts. ## Person List Filtering (extended) Additional filter parameters on `GET /organisations/{org}/events/{event}/persons`: -- `?field[slug]=value` — filter by registration field value (exact match for single-value, `JSON_CONTAINS` for multiselect) - `?section_preference={section_id}` — filter by section preference (has this section as any priority) - `?has_preference=true` — only persons who submitted section preferences +Form-field-value filtering (`?field[slug]=value`) was served by the legacy +endpoints that were purged in S2a. It returns in S2b on top of +`form_values` + `form_value_options` via the FilterQueryBuilder described +in `/dev-docs/ARCH-FORM-BUILDER.md` §7. + _(Extend this contract per module as endpoints are implemented.)_ ## Platform Admin diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index da3c39ce..8afa1794 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -2955,6 +2955,30 @@ Every contract in §31.1-31.8 has a corresponding integration test in Tests are part of CI. Contract changes require test updates (and this ARCH section update) before merge. +### 31.10 Tag sync integration (BACKLOG FORM-02) + +Replaces the S1-era `TagSyncService` that read the legacy +`person_field_values` table. Purged in S2a; rebuilt in S2b/S2c against +the new FormBuilder. + +**Trigger:** `FormSubmissionSubmitted` event (ARCH §17.1) OR explicit +call from `PersonController::approve()` after status transitions to +`approved`. + +**Listener:** `SyncTagPickerValuesToUserTagsListener` — for the given +submission, finds all `form_values` whose field has +`field_type=TAG_PICKER`, and upserts rows into `user_organisation_tags` +with `source=self_reported`, respecting `person.user_id` (skip if null). +Only syncs to the subject person's user account. + +**Failure mode:** log at warning level; never throws into the submission +lifecycle. Reason: a tag-sync failure must not block registration. + +**Call site removed in S2a:** `PersonController::approve()` and +`PersonIdentityService::syncRegistrationTags()` used to call +`TagSyncService::syncFromRegistration($person)` directly. The rebuilt +flow is listener-driven — no direct service injection required. + --- ## End of ARCH v1.2 diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 1267ae92..f89f6078 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -303,6 +303,15 @@ shifts claimen zonder toegang tot de Organizer app. --- +### FORM-02 — TAG_PICKER → user_organisation_tags sync rebuild + +**Aanleiding:** TagSyncService verwijderd in S2a Form Builder legacy purge. Semantiek (TAG_PICKER-antwoorden syncen naar user_organisation_tags bij registratie-goedkeuring) blijft valide. +**Wat:** Herbouwen als listener op FormSubmissionSubmitted of FormSubmissionApproved tegen de nieuwe FormValue + TAG_PICKER field_type. Integreren met PersonController::approve() workflow zonder directe service-injection. +**Eerdere call-sites (nu verwijderd):** PersonController::approve(), PersonIdentityService::syncRegistrationTags(). +**Prioriteit:** Hoog — moet landen in S2b of S2c, vóór de frontend in S5 opnieuw op het registratieformulier aansluit. + +--- + ### SUP-01 — Leveranciersportal + productieverzoeken **Aanleiding:** Leveranciers moeten productie-informatie kunnen indienen. diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index cff27a3d..99ab31c1 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -800,7 +800,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; | `is_blacklisted` | bool | | | `admin_notes` | text nullable | Organiser-only notes | | `remarks` | text nullable | **v1.8** Volunteer-editable notes (distinct from admin_notes which is organiser-only) | -| `custom_fields` | JSON | Backward compat + truly opaque event-specific data. For queryable registration data, use `person_field_values` via `registration_form_fields` instead. | +| `custom_fields` | JSON | Backward compat + truly opaque event-specific data. For queryable registration data, use the Form Builder (`form_values` via `form_fields` — see §3.5.12). | | `deleted_at` | timestamp nullable | Soft delete | **Unique constraint:** `UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL` @@ -1568,85 +1568,14 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; --- -## 3.5.5b Registration Form Fields & Section Preferences +## 3.5.5b Section Preferences (form-builder integration) -### `registration_form_fields` - -> Event-level dynamic field definitions for registration forms. -> Replaces the need for queryable data in `persons.custom_fields` JSON. -> Organisers configure these per event to collect additional information -> during volunteer/crew registration (shirt size, dietary needs, -> compensation preference, consent, emergency contact, etc.) +> The legacy `registration_form_fields`, `person_field_values`, and +> `registration_field_templates` tables were dropped in S2a. Their +> replacement is the Form Builder schema described in §3.5.12. > -> Special field type TAG_PICKER: renders the organisation's person_tags -> as selectable options. Answers are stored in person_field_values as -> tag IDs. When the person gets a user_id (account creation or identity -> matching), TagSyncService syncs the selections to user_organisation_tags -> with source=self_reported. - -| Column | Type | Notes | -| ------------------ | ------------------ | -------------------------------------------------- | -| `id` | ULID | PK, `HasUlids` trait | -| `event_id` | ULID FK | → events (festival-level for festivals) | -| `label` | string | Display label, e.g. "Heb je voedselallergiëen?" | -| `slug` | string(100) | Auto-generated from label, used as stable key | -| `field_type` | enum | `text\|textarea\|select\|multiselect\|checkbox\|radio\|boolean\|number\|tag_picker` | -| `options` | JSON nullable | For select/multiselect/radio/checkbox: array of option strings OR option objects `{label, description?}`. NULL for tag_picker (options come from person_tags). JSON OK: opaque config. Both formats accepted; `normalized_options` accessor always returns objects. | -| `tag_category` | string(50) null | Only for tag_picker: filter tags by this category. NULL = show all active tags. | -| `is_required` | bool | Field must be filled in | -| `is_portal_visible`| bool | Shown to person in registration form | -| `is_admin_only` | bool | Only visible in organiser backend | -| `is_filterable` | bool | Available as filter in person list / shift assignment | -| `section` | string(100) null | Form section grouping (e.g. "Vergoeding", "Toestemming") | -| `help_text` | text nullable | Explanatory text shown below the field | -| `sort_order` | int | Display order in form | -| `display_width` | string(10) | `full` (default) or `half` — controls form layout width | -| `created_at` | timestamp | | -| `updated_at` | timestamp | | - -**Unique constraint:** `UNIQUE(event_id, slug)` -**Indexes:** `(event_id, sort_order)`, `(event_id, is_portal_visible, sort_order)` -**No soft delete** — deactivation by deleting the field; existing answers remain for history. - -Design notes: -- `options` JSON is acceptable here: it's opaque configuration (the list of choices), - not queryable data. The queryable answers are stored in `person_field_values`. -- `slug` enables stable references across API calls and form submissions even if - `label` changes. -- Fields scoped to event level. For festivals, `event_id` = parent festival - (matching `persons.event_id`). -- `tag_picker` fields do NOT use `options` — available choices come from - `person_tags` filtered by `tag_category` (or all active tags if null). - ---- - -### `person_field_values` - -> Stores each person's answers to registration form fields. -> One row per person per field. Queryable via standard SQL. - -| Column | Type | Notes | -| ----------------------------- | ------------- | ----------------------------------------------------- | -| `id` | int AI | PK — high volume, pivot-like | -| `person_id` | ULID FK | → persons | -| `registration_form_field_id` | ULID FK | → registration_form_fields | -| `value` | text nullable | For text/textarea/select/radio/boolean/number | -| `selected_options` | JSON nullable | For multiselect/checkbox: array of selected option strings. For tag_picker: array of person_tag_id ULIDs. | - -**Unique constraint:** `UNIQUE(person_id, registration_form_field_id)` -**Indexes:** `(registration_form_field_id, value(191))` — for filtering on field values -**No soft delete** — immutable answers. If field definition is deleted, answers remain. - -Design notes: -- `selected_options` JSON is used ONLY for multiselect/checkbox/tag_picker fields - where multiple values must be stored. For single-value fields, use `value` only. -- Filtering on multiselect: use MySQL `JSON_CONTAINS()`. Acceptable because - multiselect filtering is a low-frequency organiser query, not a hot path. -- Integer PK for join performance (high volume table). -- For `tag_picker` fields: `selected_options` contains person_tag_id ULIDs, - not tag names. This ensures referential integrity. - ---- +> `person_section_preferences` is retained — it remains the integration +> target for the Form Builder's SECTION_PRIORITY field type (ARCH §31.3). ### `person_section_preferences` @@ -1674,78 +1603,6 @@ Design notes: --- -### Tag Sync Architecture - -> When a `tag_picker` registration field is used, tag selections are stored -> in `person_field_values` as person_tag_id ULIDs. These must be synced to -> `user_organisation_tags` when the person gets a `user_id`. - -**Service:** `TagSyncService::syncFromRegistration(Person $person): void` - -Single responsibility: reads tag_picker field values for this person, syncs -them to `user_organisation_tags` with `source = self_reported`. Uses the -existing sync behaviour: replaces only `self_reported` tags, never touches -`organiser_assigned` tags. - -**Trigger points (callers):** -1. `RegistrationFormFieldService::upsertPersonValues()` — if person already has user_id -2. `PersonService::approve()` — when account is created and user_id is set -3. `PersonIdentityService::confirmMatch()` — when user_id is linked via identity matching - -**Idempotent:** Safe to call multiple times. If tags already exist, no action. -If self_reported tags were removed by organiser, they are re-created from the -latest registration data (the volunteer still claims them). - ---- - -### `registration_field_templates` - -> Organisation-level reusable field templates. Pre-populated with system -> defaults when an organisation is created (same pattern as crowd_types). -> Organisers can customize system templates and add their own. -> When adding a field to an event's registration form, the organiser picks -> from templates — a COPY is created as a registration_form_field on the event. -> The event field is independent; changes don't propagate back to the template. - -| Column | Type | Notes | -| ------------------ | ------------------ | -------------------------------------------------- | -| `id` | ULID | PK, `HasUlids` trait | -| `organisation_id` | ULID FK | → organisations | -| `label` | string | e.g. "Shirtmaat" | -| `slug` | string(100) | Auto-generated from label | -| `field_type` | enum | Same RegistrationFieldType enum | -| `options` | JSON nullable | Predefined choices for select/multiselect/etc. | -| `tag_category` | string(50) null | Only for tag_picker | -| `is_required` | bool | Suggested default when creating event field | -| `is_filterable` | bool | Suggested default | -| `is_portal_visible`| bool | Suggested default | -| `is_admin_only` | bool | Suggested default | -| `section` | string(100) null | Suggested form section | -| `help_text` | text nullable | Suggested help text | -| `sort_order` | int | | -| `is_system` | bool | true = shipped with Crewli, false = org-created | -| `is_active` | bool | Deactivate without deleting | -| `created_at` | timestamp | | -| `updated_at` | timestamp | | - -**Unique constraint:** `UNIQUE(organisation_id, slug)` -**Indexes:** `(organisation_id, is_active, sort_order)` -**No soft delete** — deactivation via `is_active = false` - -Design notes: -- Follows the same pattern as `crowd_types`: org-level definitions, seeded - with system defaults on organisation creation. -- System templates (`is_system = true`) can be customized per org (label, - options, etc.) but cannot be deleted — only deactivated. -- Org-created templates (`is_system = false`) can be fully deleted. -- No FK from `registration_form_fields` to templates — the copy is independent. -- System templates seeded: Shirtmaat, Dieetwensen, Vergoeding, Toestemming - gegevensverwerking, Noodcontact naam, Noodcontact telefoon, EHBO/BHV, - Rijbewijs, Eerder vrijwilliger geweest, Certificaten & vaardigheden - (tag_picker), Opmerkingen. - ---- - ## 3.5.11 Database Design Rules & Index Strategy ### Rule 1 — ULID as Primary Key @@ -1788,10 +1645,7 @@ Design notes: | `shift_waitlist` | `(shift_id, position)` | | `performances` | `(stage_id, date, start_time, end_time)` | | `advance_sections` | `(artist_id, is_open)`, `(artist_id, submission_status)` | -| `registration_form_fields` | `UNIQUE(event_id, slug)`, `(event_id, sort_order)` | -| `person_field_values` | `UNIQUE(person_id, registration_form_field_id)`, `(registration_form_field_id, value(191))` | | `person_section_preferences` | `UNIQUE(person_id, festival_section_id)`, `(festival_section_id, priority)` | -| `registration_field_templates` | `UNIQUE(organisation_id, slug)`, `(organisation_id, is_active, sort_order)` | --- @@ -1940,15 +1794,14 @@ Immutable audit record of every email sent. No soft deletes. > specification. This SCHEMA.md section is a summary only and will be > fully rewritten at the end of S6. > -> **Legacy tables retained intentionally.** The tables -> `registration_form_fields`, `person_field_values`, and -> `registration_field_templates` remain in the schema through end of S1. -> They are dropped atomically in the first S2 commit together with the -> removal of legacy controllers, services, requests, resources, policies, -> and routes. DevSeeder and FormBuilderDevSeeder no longer write to them; -> they hold zero rows in dev but the schema is preserved for environments -> with real legacy data that will be migrated via -> `forms:migrate-legacy-data`. +> **Legacy tables dropped (S2a).** The tables `registration_form_fields`, +> `person_field_values`, and `registration_field_templates` were dropped +> by migration `2026_04_20_100000_drop_remaining_legacy_registration_tables` +> together with the removal of legacy controllers, services, requests, +> resources, policies, routes, and models. The migration's `down()` is a +> one-way failure — restoration requires `migrate:fresh` or a pre-S2a +> backup. Environments with real legacy data must run +> `forms:migrate-legacy-data` BEFORE applying the S2a migration. **Crosswalk: legacy `volunteer_profiles` → new locations**