diff --git a/api/app/Enums/RegistrationFieldType.php b/api/app/Enums/RegistrationFieldType.php new file mode 100644 index 00000000..64ce3df2 --- /dev/null +++ b/api/app/Enums/RegistrationFieldType.php @@ -0,0 +1,33 @@ +service->getPersonValues($person); + + return PersonFieldValueResource::collection($values); + } + + public function upsert(UpsertPersonFieldValuesRequest $request, Event $event, Person $person): JsonResponse + { + 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 new file mode 100644 index 00000000..478557e6 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/PersonSectionPreferenceController.php @@ -0,0 +1,42 @@ +service->getPreferences($person); + + return PersonSectionPreferenceResource::collection($preferences); + } + + public function replace(ReplacePersonSectionPreferencesRequest $request, Event $event, Person $person): JsonResponse + { + 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/RegistrationFieldTemplateController.php b/api/app/Http/Controllers/Api/V1/RegistrationFieldTemplateController.php new file mode 100644 index 00000000..258561b3 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/RegistrationFieldTemplateController.php @@ -0,0 +1,62 @@ +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 new file mode 100644 index 00000000..ac57d7dc --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/RegistrationFormFieldController.php @@ -0,0 +1,108 @@ +service->listForEvent($event); + + return RegistrationFormFieldResource::collection($fields); + } + + public function store(StoreRegistrationFormFieldRequest $request, Event $event): JsonResponse + { + Gate::authorize('create', [RegistrationFormField::class, $event]); + + $field = $this->service->createField($event, $request->validated()); + + return $this->created(new RegistrationFormFieldResource($field)); + } + + public function update( + UpdateRegistrationFormFieldRequest $request, + Event $event, + RegistrationFormField $registrationField, + ): JsonResponse { + Gate::authorize('update', [$registrationField, $event]); + + $field = $this->service->updateField($registrationField, $request->validated()); + + return $this->success(new RegistrationFormFieldResource($field)); + } + + public function destroy(Event $event, RegistrationFormField $registrationField): JsonResponse + { + Gate::authorize('delete', [$registrationField, $event]); + + $this->service->deleteField($registrationField); + + return response()->json(null, 204); + } + + public function reorder(ReorderRegistrationFormFieldsRequest $request, Event $event): JsonResponse + { + Gate::authorize('reorder', [RegistrationFormField::class, $event]); + + $this->service->reorderFields($event, $request->validated()['ids']); + + return response()->json(null, 204); + } + + public function fromTemplate(Request $request, Event $event): JsonResponse + { + 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, Event $event): JsonResponse + { + 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/Requests/Api/V1/ImportFromEventRequest.php b/api/app/Http/Requests/Api/V1/ImportFromEventRequest.php new file mode 100644 index 00000000..862986c4 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/ImportFromEventRequest.php @@ -0,0 +1,41 @@ + */ + public function rules(): array + { + return [ + 'source_event_id' => ['required', 'ulid', 'exists:events,id'], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $sourceEventId = $this->input('source_event_id'); + if (!$sourceEventId) { + return; + } + + $sourceEvent = Event::find($sourceEventId); + $targetEvent = $this->route('event'); + + if ($sourceEvent && $targetEvent && $sourceEvent->organisation_id !== $targetEvent->organisation_id) { + $validator->errors()->add('source_event_id', 'Source event must belong to the same organisation.'); + } + }); + } +} diff --git a/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php b/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php new file mode 100644 index 00000000..80f39abc --- /dev/null +++ b/api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'ulid', 'exists:registration_form_fields,id'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/ReplacePersonSectionPreferencesRequest.php b/api/app/Http/Requests/Api/V1/ReplacePersonSectionPreferencesRequest.php new file mode 100644 index 00000000..21854fec --- /dev/null +++ b/api/app/Http/Requests/Api/V1/ReplacePersonSectionPreferencesRequest.php @@ -0,0 +1,72 @@ + */ + 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/StoreEventRequest.php b/api/app/Http/Requests/Api/V1/StoreEventRequest.php index 770c40dd..9bf4f02f 100644 --- a/api/app/Http/Requests/Api/V1/StoreEventRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreEventRequest.php @@ -27,6 +27,8 @@ final class StoreEventRequest extends FormRequest 'event_type' => ['nullable', 'in:event,festival,series'], 'event_type_label' => ['nullable', 'string', 'max:50'], 'sub_event_label' => ['nullable', 'string', 'max:50'], + 'registration_show_section_preferences' => ['nullable', 'boolean'], + 'registration_show_availability' => ['nullable', 'boolean'], ]; } } diff --git a/api/app/Http/Requests/Api/V1/StorePersonRequest.php b/api/app/Http/Requests/Api/V1/StorePersonRequest.php index da2a5c72..6f66f81b 100644 --- a/api/app/Http/Requests/Api/V1/StorePersonRequest.php +++ b/api/app/Http/Requests/Api/V1/StorePersonRequest.php @@ -25,6 +25,7 @@ final class StorePersonRequest extends FormRequest 'phone' => ['nullable', 'string', 'max:30'], 'company_id' => ['nullable', 'ulid', 'exists:companies,id'], 'status' => ['nullable', 'in:invited,applied,pending,approved,rejected,no_show'], + 'remarks' => ['nullable', 'string', 'max:5000'], 'custom_fields' => ['nullable', 'array'], ]; } diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php new file mode 100644 index 00000000..dcb173fb --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php @@ -0,0 +1,47 @@ + */ + public function rules(): array + { + $fieldType = $this->input('field_type'); + $type = RegistrationFieldType::tryFrom($fieldType); + + return [ + 'label' => ['required', 'string', 'max:255'], + 'field_type' => ['required', Rule::in(array_column(RegistrationFieldType::cases(), 'value'))], + 'options' => [ + $type?->requiresOptions() ? 'required' : 'nullable', + $type?->prohibitsOptions() ? 'prohibited' : 'nullable', + 'array', + ], + 'options.*' => ['string', 'max:255'], + 'tag_category' => [ + $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', + 'string', + 'max:50', + ], + 'is_required' => ['nullable', 'boolean'], + 'is_filterable' => ['nullable', 'boolean'], + 'is_portal_visible' => ['nullable', 'boolean'], + 'is_admin_only' => ['nullable', 'boolean'], + 'section' => ['nullable', 'string', 'max:100'], + 'help_text' => ['nullable', 'string', 'max:5000'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php new file mode 100644 index 00000000..c2a6431c --- /dev/null +++ b/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php @@ -0,0 +1,47 @@ + */ + public function rules(): array + { + $fieldType = $this->input('field_type'); + $type = RegistrationFieldType::tryFrom($fieldType); + + return [ + 'label' => ['required', 'string', 'max:255'], + 'field_type' => ['required', Rule::in(array_column(RegistrationFieldType::cases(), 'value'))], + 'options' => [ + $type?->requiresOptions() ? 'required' : 'nullable', + $type?->prohibitsOptions() ? 'prohibited' : 'nullable', + 'array', + ], + 'options.*' => ['string', 'max:255'], + 'tag_category' => [ + $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', + 'string', + 'max:50', + ], + 'is_required' => ['nullable', 'boolean'], + 'is_portal_visible' => ['nullable', 'boolean'], + 'is_admin_only' => ['nullable', 'boolean'], + 'is_filterable' => ['nullable', 'boolean'], + 'section' => ['nullable', 'string', 'max:100'], + 'help_text' => ['nullable', 'string', 'max:5000'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php index 2f63ac5c..b8889ba6 100644 --- a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php @@ -28,6 +28,8 @@ final class UpdateEventRequest extends FormRequest 'event_type_label' => ['nullable', 'string', 'max:50'], 'sub_event_label' => ['nullable', 'string', 'max:50'], 'registration_welcome_text' => ['nullable', 'string', 'max:1000'], + 'registration_show_section_preferences' => ['nullable', 'boolean'], + 'registration_show_availability' => ['nullable', 'boolean'], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php index aa2035a8..ddbfcd40 100644 --- a/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php @@ -27,6 +27,7 @@ final class UpdatePersonRequest extends FormRequest 'status' => ['sometimes', 'in:invited,applied,pending,approved,rejected,no_show'], 'is_blacklisted' => ['sometimes', 'boolean'], 'admin_notes' => ['nullable', 'string'], + 'remarks' => ['nullable', 'string', 'max:5000'], 'custom_fields' => ['nullable', 'array'], ]; } diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php new file mode 100644 index 00000000..949fea77 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php @@ -0,0 +1,34 @@ + */ + public function rules(): array + { + return [ + 'label' => ['sometimes', 'string', 'max:255'], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:255'], + 'tag_category' => ['nullable', 'string', 'max:50'], + 'is_required' => ['nullable', 'boolean'], + 'is_filterable' => ['nullable', 'boolean'], + 'is_portal_visible' => ['nullable', 'boolean'], + 'is_admin_only' => ['nullable', 'boolean'], + 'section' => ['nullable', 'string', 'max:100'], + 'help_text' => ['nullable', 'string', 'max:5000'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php new file mode 100644 index 00000000..d344bb90 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php @@ -0,0 +1,33 @@ + */ + public function rules(): array + { + return [ + 'label' => ['sometimes', 'string', 'max:255'], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:255'], + 'tag_category' => ['nullable', 'string', 'max:50'], + 'is_required' => ['nullable', 'boolean'], + 'is_portal_visible' => ['nullable', 'boolean'], + 'is_admin_only' => ['nullable', 'boolean'], + 'is_filterable' => ['nullable', 'boolean'], + 'section' => ['nullable', 'string', 'max:100'], + 'help_text' => ['nullable', 'string', 'max:5000'], + 'sort_order' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpsertPersonFieldValuesRequest.php b/api/app/Http/Requests/Api/V1/UpsertPersonFieldValuesRequest.php new file mode 100644 index 00000000..49f50e31 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpsertPersonFieldValuesRequest.php @@ -0,0 +1,133 @@ + */ + 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/EventResource.php b/api/app/Http/Resources/Api/V1/EventResource.php index 8b7a3de2..4ca477ea 100644 --- a/api/app/Http/Resources/Api/V1/EventResource.php +++ b/api/app/Http/Resources/Api/V1/EventResource.php @@ -30,6 +30,8 @@ final class EventResource extends JsonResource 'registration_banner_url' => $this->registration_banner_url, 'registration_welcome_text' => $this->registration_welcome_text, 'registration_logo_url' => $this->registration_logo_url, + 'registration_show_section_preferences' => $this->registration_show_section_preferences, + 'registration_show_availability' => $this->registration_show_availability, 'is_festival' => $this->resource->isFestival(), 'is_sub_event' => $this->resource->isSubEvent(), 'is_flat_event' => $this->resource->isFlatEvent(), diff --git a/api/app/Http/Resources/Api/V1/PersonFieldValueResource.php b/api/app/Http/Resources/Api/V1/PersonFieldValueResource.php new file mode 100644 index 00000000..f225dce7 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/PersonFieldValueResource.php @@ -0,0 +1,34 @@ +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 a1470ea0..14ed0e60 100644 --- a/api/app/Http/Resources/Api/V1/PersonResource.php +++ b/api/app/Http/Resources/Api/V1/PersonResource.php @@ -23,6 +23,7 @@ final class PersonResource extends JsonResource 'status' => $this->status, 'is_blacklisted' => $this->is_blacklisted, 'admin_notes' => $this->admin_notes, + 'remarks' => $this->remarks, 'custom_fields' => $this->custom_fields, 'created_at' => $this->created_at->toIso8601String(), 'crowd_type' => new CrowdTypeResource($this->whenLoaded('crowdType')), @@ -53,6 +54,8 @@ 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 new file mode 100644 index 00000000..aee58ef3 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/PersonSectionPreferenceResource.php @@ -0,0 +1,33 @@ +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 new file mode 100644 index 00000000..6a069d87 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php @@ -0,0 +1,35 @@ + $this->id, + 'organisation_id' => $this->organisation_id, + 'label' => $this->label, + 'slug' => $this->slug, + 'field_type' => $this->field_type->value, + 'options' => $this->options, + 'tag_category' => $this->tag_category, + 'is_required' => $this->is_required, + 'is_filterable' => $this->is_filterable, + 'is_portal_visible' => $this->is_portal_visible, + 'is_admin_only' => $this->is_admin_only, + 'section' => $this->section, + 'help_text' => $this->help_text, + 'sort_order' => $this->sort_order, + '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 new file mode 100644 index 00000000..b06cbfef --- /dev/null +++ b/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php @@ -0,0 +1,48 @@ + $this->id, + 'event_id' => $this->event_id, + 'label' => $this->label, + 'slug' => $this->slug, + 'field_type' => $this->field_type->value, + 'options' => $this->options, + 'tag_category' => $this->tag_category, + 'is_required' => $this->is_required, + 'is_portal_visible' => $this->is_portal_visible, + 'is_admin_only' => $this->is_admin_only, + 'is_filterable' => $this->is_filterable, + 'section' => $this->section, + 'help_text' => $this->help_text, + 'sort_order' => $this->sort_order, + '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 ($this->tag_category) { + $query->where('category', $this->tag_category); + } + + return PersonTagResource::collection($query->orderBy('sort_order')->get()); + } + ), + ]; + } +} diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index 9963dc12..01afe3f7 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -66,6 +66,8 @@ final class Event extends Model 'registration_banner_url', 'registration_welcome_text', 'registration_logo_url', + 'registration_show_section_preferences', + 'registration_show_availability', ]; protected function casts(): array @@ -76,6 +78,8 @@ final class Event extends Model 'is_recurring' => 'boolean', 'recurrence_exceptions' => 'array', 'event_type' => 'string', + 'registration_show_section_preferences' => 'boolean', + 'registration_show_availability' => 'boolean', ]; } diff --git a/api/app/Models/Organisation.php b/api/app/Models/Organisation.php index 375b60f7..c20eb580 100644 --- a/api/app/Models/Organisation.php +++ b/api/app/Models/Organisation.php @@ -62,4 +62,9 @@ final class Organisation extends Model { return $this->hasMany(PersonTag::class); } + + public function registrationFieldTemplates(): HasMany + { + return $this->hasMany(RegistrationFieldTemplate::class); + } } diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index d0a72836..b793ceee 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -36,6 +36,7 @@ final class Person extends Model 'status', 'is_blacklisted', 'admin_notes', + 'remarks', 'custom_fields', ]; @@ -94,6 +95,16 @@ final class Person extends Model return $this->hasMany(VolunteerAvailability::class); } + public function fieldValues(): HasMany + { + return $this->hasMany(PersonFieldValue::class); + } + + public function sectionPreferences(): HasMany + { + return $this->hasMany(PersonSectionPreference::class); + } + public function identityMatches(): HasMany { return $this->hasMany(PersonIdentityMatch::class); diff --git a/api/app/Models/PersonFieldValue.php b/api/app/Models/PersonFieldValue.php new file mode 100644 index 00000000..8edca6ac --- /dev/null +++ b/api/app/Models/PersonFieldValue.php @@ -0,0 +1,37 @@ + '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/PersonSectionPreference.php b/api/app/Models/PersonSectionPreference.php new file mode 100644 index 00000000..01f4759c --- /dev/null +++ b/api/app/Models/PersonSectionPreference.php @@ -0,0 +1,36 @@ + 'integer', + ]; + } + + public function person(): BelongsTo + { + return $this->belongsTo(Person::class); + } + + public function festivalSection(): BelongsTo + { + return $this->belongsTo(FestivalSection::class); + } +} diff --git a/api/app/Models/RegistrationFieldTemplate.php b/api/app/Models/RegistrationFieldTemplate.php new file mode 100644 index 00000000..d77723f3 --- /dev/null +++ b/api/app/Models/RegistrationFieldTemplate.php @@ -0,0 +1,71 @@ + RegistrationFieldType::class, + 'options' => 'array', + 'is_required' => 'boolean', + 'is_filterable' => 'boolean', + 'is_portal_visible' => 'boolean', + 'is_admin_only' => 'boolean', + 'sort_order' => 'integer', + 'is_system' => 'boolean', + 'is_active' => 'boolean', + ]; + } + + 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 new file mode 100644 index 00000000..7ddc5218 --- /dev/null +++ b/api/app/Models/RegistrationFormField.php @@ -0,0 +1,73 @@ + RegistrationFieldType::class, + 'options' => 'array', + 'is_required' => 'boolean', + 'is_portal_visible' => 'boolean', + 'is_admin_only' => 'boolean', + 'is_filterable' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function personFieldValues(): HasMany + { + return $this->hasMany(PersonFieldValue::class, 'registration_form_field_id'); + } + + 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 new file mode 100644 index 00000000..2aadb4cf --- /dev/null +++ b/api/app/Policies/RegistrationFieldTemplatePolicy.php @@ -0,0 +1,53 @@ +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 new file mode 100644 index 00000000..ade9fe7b --- /dev/null +++ b/api/app/Policies/RegistrationFormFieldPolicy.php @@ -0,0 +1,84 @@ +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/Services/PersonSectionPreferenceService.php b/api/app/Services/PersonSectionPreferenceService.php new file mode 100644 index 00000000..16a54996 --- /dev/null +++ b/api/app/Services/PersonSectionPreferenceService.php @@ -0,0 +1,51 @@ +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 new file mode 100644 index 00000000..d6c16b54 --- /dev/null +++ b/api/app/Services/RegistrationFieldTemplateService.php @@ -0,0 +1,199 @@ +registrationFieldTemplates() + ->active() + ->ordered() + ->get(); + } + + public function createTemplate(Organisation $organisation, array $data): RegistrationFieldTemplate + { + $data['slug'] = $this->generateUniqueSlug($organisation, $data['label']); + + $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_category' => $template->tag_category, + 'is_required' => $template->is_required, + 'is_portal_visible' => $template->is_portal_visible, + 'is_admin_only' => $template->is_admin_only, + 'is_filterable' => $template->is_filterable, + 'section' => $template->section, + 'help_text' => $template->help_text, + 'sort_order' => $maxOrder + 1, + ]); + + $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' => 'Shirtmaat', 'field_type' => 'select', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, 'sort_order' => 1], + ['label' => 'Dieetwensen', 'field_type' => 'multiselect', 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', 'Geen pinda\'s', 'Geen noten'], 'is_filterable' => true, 'sort_order' => 2], + ['label' => 'Vergoeding', 'field_type' => 'radio', 'options' => ['Pro Deo', 'Entreeticket', 'Vrijwilligersvergoeding'], 'section' => 'Vergoeding', 'sort_order' => 3], + ['label' => 'Toestemming gegevensverwerking', 'field_type' => 'boolean', 'is_required' => true, 'section' => 'Toestemming', '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).', 'sort_order' => 4], + ['label' => 'Noodcontact naam', 'field_type' => 'text', 'section' => 'Noodcontact', 'sort_order' => 5], + ['label' => 'Noodcontact telefoon', 'field_type' => 'text', 'section' => 'Noodcontact', 'sort_order' => 6], + ['label' => 'EHBO / BHV diploma', 'field_type' => 'boolean', 'is_filterable' => true, 'sort_order' => 7], + ['label' => 'Rijbewijs', 'field_type' => 'boolean', 'is_filterable' => true, 'sort_order' => 8], + ['label' => 'Eerder vrijwilliger geweest', 'field_type' => 'boolean', 'is_filterable' => true, 'sort_order' => 9], + ['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_category' => null, 'is_filterable' => true, 'sort_order' => 10], + ['label' => 'Opmerkingen', 'field_type' => 'textarea', 'sort_order' => 11], + ]; + + 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, + ]); + } + } + + 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 new file mode 100644 index 00000000..a544e1e2 --- /dev/null +++ b/api/app/Services/RegistrationFormFieldService.php @@ -0,0 +1,219 @@ +id) + ->ordered() + ->get(); + } + + public function createField(Event $event, array $data): RegistrationFormField + { + $data['slug'] = $this->generateUniqueSlug($event, $data['label']); + + $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) { + 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_category' => $sourceField->tag_category, + 'is_required' => $sourceField->is_required, + 'is_portal_visible' => $sourceField->is_portal_visible, + 'is_admin_only' => $sourceField->is_admin_only, + 'is_filterable' => $sourceField->is_filterable, + 'section' => $sourceField->section, + 'help_text' => $sourceField->help_text, + 'sort_order' => ++$maxOrder, + ]); + + $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 new file mode 100644 index 00000000..f4fcc5a8 --- /dev/null +++ b/api/app/Services/TagSyncService.php @@ -0,0 +1,93 @@ +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/database/factories/RegistrationFieldTemplateFactory.php b/api/database/factories/RegistrationFieldTemplateFactory.php new file mode 100644 index 00000000..7946e90a --- /dev/null +++ b/api/database/factories/RegistrationFieldTemplateFactory.php @@ -0,0 +1,82 @@ + */ +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_category' => null, + 'is_required' => false, + 'is_filterable' => false, + 'is_portal_visible' => true, + 'is_admin_only' => false, + 'section' => null, + 'help_text' => null, + 'sort_order' => fake()->numberBetween(0, 20), + '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, + ]); + } + + public function booleanField(): static + { + return $this->state(fn () => [ + 'label' => 'EHBO / BHV diploma', + 'slug' => 'ehbo-bhv-diploma', + 'field_type' => RegistrationFieldType::BOOLEAN, + 'is_filterable' => true, + ]); + } + + public function tagPickerField(): static + { + return $this->state(fn () => [ + 'label' => 'Certificaten & vaardigheden', + 'slug' => 'certificaten-vaardigheden', + 'field_type' => RegistrationFieldType::TAG_PICKER, + 'is_filterable' => true, + ]); + } +} diff --git a/api/database/factories/RegistrationFormFieldFactory.php b/api/database/factories/RegistrationFormFieldFactory.php new file mode 100644 index 00000000..31635bc3 --- /dev/null +++ b/api/database/factories/RegistrationFormFieldFactory.php @@ -0,0 +1,113 @@ + */ +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_category' => null, + 'is_required' => false, + 'is_portal_visible' => true, + 'is_admin_only' => false, + 'is_filterable' => false, + 'section' => null, + 'help_text' => null, + 'sort_order' => fake()->numberBetween(0, 20), + ]; + } + + public function textField(): static + { + return $this->state(fn () => [ + 'label' => 'Noodcontact naam', + 'slug' => 'noodcontact-naam', + 'field_type' => RegistrationFieldType::TEXT, + 'section' => 'Noodcontact', + ]); + } + + 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, + ]); + } + + 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, + ]); + } + + public function booleanField(): static + { + return $this->state(fn () => [ + 'label' => 'Toestemming gegevensverwerking', + 'slug' => 'toestemming-gegevensverwerking', + 'field_type' => RegistrationFieldType::BOOLEAN, + 'is_required' => true, + 'section' => 'Toestemming', + 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens conform de AVG.', + ]); + } + + public function tagPickerField(): static + { + return $this->state(fn () => [ + 'label' => 'Vaardigheden', + 'slug' => 'vaardigheden', + 'field_type' => RegistrationFieldType::TAG_PICKER, + 'is_filterable' => true, + ]); + } + + public function radioField(): static + { + return $this->state(fn () => [ + 'label' => 'Vergoeding', + 'slug' => 'vergoeding', + 'field_type' => RegistrationFieldType::RADIO, + 'options' => ['Pro Deo', 'Entreeticket', 'Vrijwilligersvergoeding'], + 'section' => 'Vergoeding', + ]); + } + + public function textareaField(): static + { + return $this->state(fn () => [ + 'label' => 'Opmerkingen', + 'slug' => 'opmerkingen', + 'field_type' => RegistrationFieldType::TEXTAREA, + ]); + } +} diff --git a/api/database/migrations/2026_04_12_100000_add_remarks_to_persons_table.php b/api/database/migrations/2026_04_12_100000_add_remarks_to_persons_table.php new file mode 100644 index 00000000..4e040690 --- /dev/null +++ b/api/database/migrations/2026_04_12_100000_add_remarks_to_persons_table.php @@ -0,0 +1,24 @@ +text('remarks')->nullable()->after('admin_notes'); + }); + } + + public function down(): void + { + Schema::table('persons', function (Blueprint $table) { + $table->dropColumn('remarks'); + }); + } +}; diff --git a/api/database/migrations/2026_04_12_100001_add_registration_toggles_to_events_table.php b/api/database/migrations/2026_04_12_100001_add_registration_toggles_to_events_table.php new file mode 100644 index 00000000..6be4d4cb --- /dev/null +++ b/api/database/migrations/2026_04_12_100001_add_registration_toggles_to_events_table.php @@ -0,0 +1,28 @@ +boolean('registration_show_section_preferences')->default(true)->after('registration_logo_url'); + $table->boolean('registration_show_availability')->default(true)->after('registration_show_section_preferences'); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn([ + 'registration_show_section_preferences', + 'registration_show_availability', + ]); + }); + } +}; diff --git a/api/database/migrations/2026_04_12_200000_create_registration_field_templates_table.php b/api/database/migrations/2026_04_12_200000_create_registration_field_templates_table.php new file mode 100644 index 00000000..c8e6a154 --- /dev/null +++ b/api/database/migrations/2026_04_12_200000_create_registration_field_templates_table.php @@ -0,0 +1,41 @@ +ulid('id')->primary(); + $table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete(); + $table->string('label'); + $table->string('slug', 100); + $table->string('field_type', 50); + $table->json('options')->nullable(); + $table->string('tag_category', 50)->nullable(); + $table->boolean('is_required')->default(false); + $table->boolean('is_filterable')->default(false); + $table->boolean('is_portal_visible')->default(true); + $table->boolean('is_admin_only')->default(false); + $table->string('section', 100)->nullable(); + $table->text('help_text')->nullable(); + $table->integer('sort_order')->default(0); + $table->boolean('is_system')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['organisation_id', 'slug'], 'rft_org_slug_unique'); + $table->index(['organisation_id', 'is_active', 'sort_order'], 'rft_org_active_order_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('registration_field_templates'); + } +}; diff --git a/api/database/migrations/2026_04_12_200001_create_registration_form_fields_table.php b/api/database/migrations/2026_04_12_200001_create_registration_form_fields_table.php new file mode 100644 index 00000000..9eb41bc7 --- /dev/null +++ b/api/database/migrations/2026_04_12_200001_create_registration_form_fields_table.php @@ -0,0 +1,40 @@ +ulid('id')->primary(); + $table->foreignUlid('event_id')->constrained()->cascadeOnDelete(); + $table->string('label'); + $table->string('slug', 100); + $table->string('field_type', 50); + $table->json('options')->nullable(); + $table->string('tag_category', 50)->nullable(); + $table->boolean('is_required')->default(false); + $table->boolean('is_portal_visible')->default(true); + $table->boolean('is_admin_only')->default(false); + $table->boolean('is_filterable')->default(false); + $table->string('section', 100)->nullable(); + $table->text('help_text')->nullable(); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['event_id', 'slug'], 'rff_event_slug_unique'); + $table->index(['event_id', 'sort_order'], 'rff_event_order_index'); + $table->index(['event_id', 'is_portal_visible', 'sort_order'], 'rff_event_visible_order_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('registration_form_fields'); + } +}; diff --git a/api/database/migrations/2026_04_12_200002_create_person_field_values_table.php b/api/database/migrations/2026_04_12_200002_create_person_field_values_table.php new file mode 100644 index 00000000..77c96a49 --- /dev/null +++ b/api/database/migrations/2026_04_12_200002_create_person_field_values_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete(); + $table->foreignUlid('registration_form_field_id')->nullable()->constrained('registration_form_fields')->nullOnDelete(); + $table->text('value')->nullable(); + $table->json('selected_options')->nullable(); + + $table->unique(['person_id', 'registration_form_field_id'], 'pfv_person_field_unique'); + $table->index('registration_form_field_id', 'pfv_field_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('person_field_values'); + } +}; diff --git a/api/database/migrations/2026_04_12_200003_create_person_section_preferences_table.php b/api/database/migrations/2026_04_12_200003_create_person_section_preferences_table.php new file mode 100644 index 00000000..bc00d4b1 --- /dev/null +++ b/api/database/migrations/2026_04_12_200003_create_person_section_preferences_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete(); + $table->foreignUlid('festival_section_id')->constrained('festival_sections')->cascadeOnDelete(); + $table->tinyInteger('priority'); + + $table->unique(['person_id', 'festival_section_id'], 'psp_person_section_unique'); + $table->index(['festival_section_id', 'priority']); + $table->index('person_id', 'psp_person_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('person_section_preferences'); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index deda64cb..9eeaf176 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -150,7 +150,11 @@ class DevSeeder extends Seeder $this->personTags[$data['name']] = $tag; } - $this->command->info(' Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags created'); + // ── Registration Field Templates (system defaults) ── + + \App\Services\RegistrationFieldTemplateService::seedSystemTemplates($this->org); + + $this->command->info(' Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, 11 registration templates created'); }); } diff --git a/api/routes/api.php b/api/routes/api.php index 19f7c862..8d746872 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -15,8 +15,12 @@ use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\OrganisationController; use App\Http\Controllers\Api\V1\PersonController; +use App\Http\Controllers\Api\V1\PersonFieldValueController; use App\Http\Controllers\Api\V1\PersonIdentityMatchController; +use App\Http\Controllers\Api\V1\PersonSectionPreferenceController; use App\Http\Controllers\Api\V1\PersonTagController; +use App\Http\Controllers\Api\V1\RegistrationFieldTemplateController; +use App\Http\Controllers\Api\V1\RegistrationFormFieldController; use App\Http\Controllers\Api\V1\ShiftAssignmentController; use App\Http\Controllers\Api\V1\ShiftController; use App\Http\Controllers\Api\V1\TimeSlotController; @@ -104,6 +108,10 @@ Route::middleware('auth:sanctum')->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']); @@ -160,6 +168,21 @@ Route::middleware('auth:sanctum')->group(function () { // Volunteer availabilities 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']); Route::apiResource('crowd-lists', CrowdListController::class) ->except(['show']); Route::get('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'persons']); diff --git a/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php b/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php new file mode 100644 index 00000000..223245c8 --- /dev/null +++ b/api/tests/Feature/PersonFieldValue/PersonFieldValueTest.php @@ -0,0 +1,366 @@ +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/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/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/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/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/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/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/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/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->update(['user_id' => $linkedUser->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/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/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/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/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/events/{$this->event->id}/persons/{$this->person->id}/field-values"); + + $response->assertForbidden(); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/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 new file mode 100644 index 00000000..c14dfda7 --- /dev/null +++ b/api/tests/Feature/PersonSectionPreference/PersonSectionPreferenceTest.php @@ -0,0 +1,279 @@ +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/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/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/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/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/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/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/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/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/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/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/events/{$this->subEvent->id}/persons/{$this->person->id}/section-preferences"); + + $response->assertForbidden(); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/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 new file mode 100644 index 00000000..b0c6bed2 --- /dev/null +++ b/api/tests/Feature/RegistrationFieldTemplate/RegistrationFieldTemplateTest.php @@ -0,0 +1,202 @@ +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_active_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(3, $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(11, $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/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 new file mode 100644 index 00000000..ba0c6e77 --- /dev/null +++ b/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php @@ -0,0 +1,363 @@ +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/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/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/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/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_category(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/registration-fields", [ + 'label' => 'Vaardigheden', + 'field_type' => 'tag_picker', + 'tag_category' => 'Vaardigheid', + ]); + + $response->assertCreated() + ->assertJsonPath('data.tag_category', 'Vaardigheid'); + } + + public function test_store_text_field_rejects_tag_category(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/registration-fields", [ + 'label' => 'Naam', + 'field_type' => 'text', + 'tag_category' => 'Vaardigheid', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('tag_category'); + } + + 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/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/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/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/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/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/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/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/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/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/events/{$this->event->id}/registration-fields"); + + $response->assertForbidden(); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/events/{$this->event->id}/registration-fields"); + + $response->assertUnauthorized(); + } +} diff --git a/api/tests/Feature/TagSync/TagSyncServiceTest.php b/api/tests/Feature/TagSync/TagSyncServiceTest.php new file mode 100644 index 00000000..ca8efe34 --- /dev/null +++ b/api/tests/Feature/TagSync/TagSyncServiceTest.php @@ -0,0 +1,335 @@ +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', + ]); + } +}