diff --git a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php index f2467204..b9c39218 100644 --- a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php +++ b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php @@ -98,7 +98,7 @@ final class PublicRegistrationDataController extends Controller 'field_type' => $field->field_type->value, 'options' => $field->options, 'normalized_options' => $field->normalized_options, - 'tag_category' => $field->tag_category, + 'tag_categories' => $field->tag_categories, 'is_required' => $field->is_required, 'help_text' => $field->help_text, 'display_width' => $field->display_width->value, @@ -108,11 +108,11 @@ final class PublicRegistrationDataController extends Controller $query = PersonTag::where('organisation_id', $organisationId) ->where('is_active', true); - if ($field->tag_category) { - $query->where('category', $field->tag_category); + if (!empty($field->tag_categories)) { + $query->whereIn('category', $field->tag_categories); } - $data['available_tags'] = $query->orderBy('sort_order') + $data['available_tags'] = $query->orderBy('category')->orderBy('sort_order') ->get() ->map(fn (PersonTag $tag) => [ 'id' => $tag->id, diff --git a/api/app/Http/Controllers/Api/V1/UploadController.php b/api/app/Http/Controllers/Api/V1/UploadController.php new file mode 100644 index 00000000..8d8a3e79 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/UploadController.php @@ -0,0 +1,52 @@ +validate([ + 'file' => ['required', 'file', 'max:5120'], + 'purpose' => ['required', 'string', 'in:logo,banner,icon,avatar'], + ]); + + $user = $request->user(); + $organisation = $user->organisations()->first(); + + try { + $url = $this->uploadService->uploadImage( + file: $request->file('file'), + directory: 'uploads/' . $request->input('purpose'), + organisationId: $organisation?->id, + ); + } catch (\DomainException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + activity('upload') + ->causedBy($user) + ->withProperties([ + 'purpose' => $request->input('purpose'), + 'original_name' => $request->file('file')->getClientOriginalName(), + 'size_bytes' => $request->file('file')->getSize(), + 'mime' => $request->file('file')->getMimeType(), + ]) + ->log('image.uploaded'); + + return response()->json([ + 'data' => ['url' => $url], + ]); + } +} diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php index 747b9a10..7fe1d870 100644 --- a/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php @@ -43,11 +43,11 @@ final class StoreRegistrationFieldTemplateRequest extends FormRequest 'options.*' => ['required'], 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_category' => [ + 'tag_categories' => [ $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', - 'string', - 'max:50', + 'array', ], + 'tag_categories.*' => ['string', 'max:50'], 'is_required' => ['nullable', 'boolean'], 'is_filterable' => ['nullable', 'boolean'], 'is_portal_visible' => ['nullable', 'boolean'], diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php index 43aad059..9b85fa1d 100644 --- a/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php @@ -43,11 +43,11 @@ final class StoreRegistrationFormFieldRequest extends FormRequest 'options.*' => ['required'], 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_category' => [ + 'tag_categories' => [ $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', - 'string', - 'max:50', + 'array', ], + 'tag_categories.*' => ['string', 'max:50'], 'is_required' => ['nullable', 'boolean'], 'is_portal_visible' => ['nullable', 'boolean'], 'is_admin_only' => ['nullable', 'boolean'], diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php index af0d85a9..50d60701 100644 --- a/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php @@ -25,7 +25,8 @@ final class UpdateRegistrationFieldTemplateRequest extends FormRequest 'options.*' => ['required'], 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_category' => ['nullable', 'string', 'max:50'], + 'tag_categories' => ['nullable', 'array'], + 'tag_categories.*' => ['string', 'max:50'], 'is_required' => ['nullable', 'boolean'], 'is_filterable' => ['nullable', 'boolean'], 'is_portal_visible' => ['nullable', 'boolean'], diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php index adaa48e6..9cda10e6 100644 --- a/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php @@ -24,7 +24,8 @@ final class UpdateRegistrationFormFieldRequest extends FormRequest 'options.*' => ['required'], 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], 'options.*.description' => ['nullable', 'string', 'max:200'], - 'tag_category' => ['nullable', 'string', 'max:50'], + 'tag_categories' => ['nullable', 'array'], + 'tag_categories.*' => ['string', 'max:50'], 'is_required' => ['nullable', 'boolean'], 'is_portal_visible' => ['nullable', 'boolean'], 'is_admin_only' => ['nullable', 'boolean'], diff --git a/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php b/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php index fd880671..0e69612b 100644 --- a/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php +++ b/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php @@ -19,7 +19,7 @@ final class RegistrationFieldTemplateResource extends JsonResource 'field_type' => $this->field_type->value, 'options' => $this->options, 'normalized_options' => $this->normalized_options, - 'tag_category' => $this->tag_category, + 'tag_categories' => $this->tag_categories, 'is_required' => $this->is_required, 'is_filterable' => $this->is_filterable, 'is_portal_visible' => $this->is_portal_visible, diff --git a/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php b/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php index 50a51e58..67c852b2 100644 --- a/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php +++ b/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php @@ -21,7 +21,7 @@ final class RegistrationFormFieldResource extends JsonResource 'field_type' => $this->field_type->value, 'options' => $this->options, 'normalized_options' => $this->normalized_options, - 'tag_category' => $this->tag_category, + 'tag_categories' => $this->tag_categories, 'is_required' => $this->is_required, 'is_portal_visible' => $this->is_portal_visible, 'is_admin_only' => $this->is_admin_only, @@ -37,11 +37,11 @@ final class RegistrationFormFieldResource extends JsonResource $query = PersonTag::where('organisation_id', $this->event->organisation_id) ->where('is_active', true); - if ($this->tag_category) { - $query->where('category', $this->tag_category); + if (!empty($this->tag_categories)) { + $query->whereIn('category', $this->tag_categories); } - return PersonTagResource::collection($query->orderBy('sort_order')->get()); + return PersonTagResource::collection($query->orderBy('category')->orderBy('sort_order')->get()); } ), ]; diff --git a/api/app/Models/RegistrationFieldTemplate.php b/api/app/Models/RegistrationFieldTemplate.php index 3d82820b..490dd13b 100644 --- a/api/app/Models/RegistrationFieldTemplate.php +++ b/api/app/Models/RegistrationFieldTemplate.php @@ -29,7 +29,7 @@ final class RegistrationFieldTemplate extends Model 'slug', 'field_type', 'options', - 'tag_category', + 'tag_categories', 'is_required', 'is_filterable', 'is_portal_visible', @@ -46,6 +46,7 @@ final class RegistrationFieldTemplate extends Model return [ 'field_type' => RegistrationFieldType::class, 'options' => 'array', + 'tag_categories' => 'array', 'is_required' => 'boolean', 'is_filterable' => 'boolean', 'is_portal_visible' => 'boolean', diff --git a/api/app/Models/RegistrationFormField.php b/api/app/Models/RegistrationFormField.php index 372dc5ad..32b87e83 100644 --- a/api/app/Models/RegistrationFormField.php +++ b/api/app/Models/RegistrationFormField.php @@ -33,7 +33,7 @@ final class RegistrationFormField extends Model 'slug', 'field_type', 'options', - 'tag_category', + 'tag_categories', 'is_required', 'is_portal_visible', 'is_admin_only', @@ -48,6 +48,7 @@ final class RegistrationFormField extends Model return [ 'field_type' => RegistrationFieldType::class, 'options' => 'array', + 'tag_categories' => 'array', 'is_required' => 'boolean', 'is_portal_visible' => 'boolean', 'is_admin_only' => 'boolean', diff --git a/api/app/Services/FileUploadService.php b/api/app/Services/FileUploadService.php new file mode 100644 index 00000000..94bef5e4 --- /dev/null +++ b/api/app/Services/FileUploadService.php @@ -0,0 +1,67 @@ +validateImage($file); + + $extension = $file->getClientOriginalExtension(); + $filename = Str::ulid() . '.' . $extension; + $path = trim($directory, '/'); + + if ($organisationId) { + $path .= '/' . $organisationId; + } + + Storage::disk('public')->putFileAs($path, $file, $filename); + + return Storage::disk('public')->url($path . '/' . $filename); + } + + public function deleteByUrl(string $url): void + { + $baseUrl = Storage::disk('public')->url(''); + $path = str_replace($baseUrl, '', $url); + + if ($path && Storage::disk('public')->exists($path)) { + Storage::disk('public')->delete($path); + } + } + + private function validateImage(UploadedFile $file): void + { + if (!in_array($file->getMimeType(), self::ALLOWED_IMAGE_MIMES, true)) { + throw new \DomainException( + 'Alleen JPG, PNG, WebP, SVG of GIF bestanden zijn toegestaan.' + ); + } + + if ($file->getSize() / 1024 > self::MAX_IMAGE_SIZE_KB) { + throw new \DomainException( + 'Bestand is te groot. Maximum: 5MB.' + ); + } + } +} diff --git a/api/app/Services/RegistrationFieldTemplateService.php b/api/app/Services/RegistrationFieldTemplateService.php index ef23ebf7..ae095fa7 100644 --- a/api/app/Services/RegistrationFieldTemplateService.php +++ b/api/app/Services/RegistrationFieldTemplateService.php @@ -110,7 +110,7 @@ final class RegistrationFieldTemplateService 'slug' => $slug, 'field_type' => $template->field_type, 'options' => $template->options, - 'tag_category' => $template->tag_category, + 'tag_categories' => $template->tag_categories, 'is_required' => $template->is_required, 'is_portal_visible' => $template->is_portal_visible, 'is_admin_only' => $template->is_admin_only, @@ -155,7 +155,7 @@ final class RegistrationFieldTemplateService ['label' => 'EHBO / BHV diploma', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 10], ['label' => 'Rijbewijs', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 11], ['label' => 'Eerder vrijwilliger geweest', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 12], - ['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_category' => null, 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 13], + ['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_categories' => null, 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 13], ['label' => 'Aanvullende informatie', 'field_type' => 'heading', 'display_width' => 'full', 'sort_order' => 14], ['label' => 'Toestemming gegevensverwerking', 'field_type' => 'boolean', 'is_required' => true, 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens ten behoeve van de organisatie van dit evenement, conform de Algemene Verordening Gegevensbescherming (AVG).', 'display_width' => 'full', 'sort_order' => 15], ['label' => 'Opmerkingen', 'field_type' => 'textarea', 'display_width' => 'full', 'sort_order' => 16], diff --git a/api/app/Services/RegistrationFormFieldService.php b/api/app/Services/RegistrationFormFieldService.php index 8188a439..1a14f7bd 100644 --- a/api/app/Services/RegistrationFormFieldService.php +++ b/api/app/Services/RegistrationFormFieldService.php @@ -171,7 +171,7 @@ final class RegistrationFormFieldService 'slug' => $slug, 'field_type' => $sourceField->field_type, 'options' => $sourceField->options, - 'tag_category' => $sourceField->tag_category, + 'tag_categories' => $sourceField->tag_categories, 'is_required' => $sourceField->is_required, 'is_portal_visible' => $sourceField->is_portal_visible, 'is_admin_only' => $sourceField->is_admin_only, diff --git a/api/database/factories/RegistrationFieldTemplateFactory.php b/api/database/factories/RegistrationFieldTemplateFactory.php index 256f1619..9ee5c531 100644 --- a/api/database/factories/RegistrationFieldTemplateFactory.php +++ b/api/database/factories/RegistrationFieldTemplateFactory.php @@ -27,7 +27,7 @@ final class RegistrationFieldTemplateFactory extends Factory 'slug' => Str::slug($label), 'field_type' => RegistrationFieldType::TEXT, 'options' => null, - 'tag_category' => null, + 'tag_categories' => null, 'is_required' => false, 'is_filterable' => false, 'is_portal_visible' => true, diff --git a/api/database/factories/RegistrationFormFieldFactory.php b/api/database/factories/RegistrationFormFieldFactory.php index 9a00c82f..0dde576c 100644 --- a/api/database/factories/RegistrationFormFieldFactory.php +++ b/api/database/factories/RegistrationFormFieldFactory.php @@ -27,7 +27,7 @@ final class RegistrationFormFieldFactory extends Factory 'slug' => Str::slug($label), 'field_type' => RegistrationFieldType::TEXT, 'options' => null, - 'tag_category' => null, + 'tag_categories' => null, 'is_required' => false, 'is_portal_visible' => true, 'is_admin_only' => false, diff --git a/api/database/migrations/2026_04_18_100000_change_tag_category_to_tag_categories_on_registration_fields.php b/api/database/migrations/2026_04_18_100000_change_tag_category_to_tag_categories_on_registration_fields.php new file mode 100644 index 00000000..82eac9ea --- /dev/null +++ b/api/database/migrations/2026_04_18_100000_change_tag_category_to_tag_categories_on_registration_fields.php @@ -0,0 +1,87 @@ +json('tag_categories')->nullable()->after('options'); + }); + + DB::table('registration_form_fields') + ->whereNotNull('tag_category') + ->where('tag_category', '!=', '') + ->eachById(function ($row) { + DB::table('registration_form_fields') + ->where('id', $row->id) + ->update(['tag_categories' => json_encode([$row->tag_category])]); + }); + + Schema::table('registration_form_fields', function (Blueprint $table) { + $table->dropColumn('tag_category'); + }); + + // registration_field_templates: add tag_categories JSON, migrate data, drop tag_category + Schema::table('registration_field_templates', function (Blueprint $table) { + $table->json('tag_categories')->nullable()->after('options'); + }); + + DB::table('registration_field_templates') + ->whereNotNull('tag_category') + ->where('tag_category', '!=', '') + ->eachById(function ($row) { + DB::table('registration_field_templates') + ->where('id', $row->id) + ->update(['tag_categories' => json_encode([$row->tag_category])]); + }); + + Schema::table('registration_field_templates', function (Blueprint $table) { + $table->dropColumn('tag_category'); + }); + } + + public function down(): void + { + Schema::table('registration_form_fields', function (Blueprint $table) { + $table->string('tag_category', 50)->nullable()->after('options'); + }); + + DB::table('registration_form_fields') + ->whereNotNull('tag_categories') + ->eachById(function ($row) { + $categories = json_decode($row->tag_categories, true); + DB::table('registration_form_fields') + ->where('id', $row->id) + ->update(['tag_category' => $categories[0] ?? null]); + }); + + Schema::table('registration_form_fields', function (Blueprint $table) { + $table->dropColumn('tag_categories'); + }); + + Schema::table('registration_field_templates', function (Blueprint $table) { + $table->string('tag_category', 50)->nullable()->after('options'); + }); + + DB::table('registration_field_templates') + ->whereNotNull('tag_categories') + ->eachById(function ($row) { + $categories = json_decode($row->tag_categories, true); + DB::table('registration_field_templates') + ->where('id', $row->id) + ->update(['tag_category' => $categories[0] ?? null]); + }); + + Schema::table('registration_field_templates', function (Blueprint $table) { + $table->dropColumn('tag_categories'); + }); + } +}; diff --git a/api/database/migrations/2026_04_18_100001_update_partner_crowd_type_icon.php b/api/database/migrations/2026_04_18_100001_update_partner_crowd_type_icon.php new file mode 100644 index 00000000..c24c16b0 --- /dev/null +++ b/api/database/migrations/2026_04_18_100001_update_partner_crowd_type_icon.php @@ -0,0 +1,29 @@ +where('system_type', 'PARTNER') + ->where(function ($query) { + $query->whereNull('icon') + ->orWhere('icon', '') + ->orWhere('icon', 'tabler-handshake'); + }) + ->update(['icon' => 'tabler-affiliate']); + } + + public function down(): void + { + DB::table('crowd_types') + ->where('system_type', 'PARTNER') + ->where('icon', 'tabler-affiliate') + ->update(['icon' => 'tabler-handshake']); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index a029b033..b99fb9df 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -124,7 +124,7 @@ class DevSeeder extends Seeder ['name' => 'Pers', 'system_type' => 'PRESS', 'color' => '#FF9800', 'icon' => 'tabler-camera'], ['name' => 'Gast', 'system_type' => 'GUEST', 'color' => '#607D8B', 'icon' => 'tabler-ticket'], ['name' => 'Leverancier', 'system_type' => 'SUPPLIER', 'color' => '#795548', 'icon' => 'tabler-truck-delivery'], - ['name' => 'Partner', 'system_type' => 'PARTNER', 'color' => '#FFC107', 'icon' => 'tabler-handshake'], + ['name' => 'Partner', 'system_type' => 'PARTNER', 'color' => '#FFC107', 'icon' => 'tabler-affiliate'], ]; foreach ($crowdTypesData as $data) { diff --git a/api/routes/api.php b/api/routes/api.php index 433e9486..a00ce9e6 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -38,6 +38,7 @@ use App\Http\Controllers\Api\V1\EmailChangeController; use App\Http\Controllers\Api\V1\PasswordResetController; use App\Http\Controllers\Api\V1\PortalMeController; use App\Http\Controllers\Api\V1\Portal\PortalShiftController; +use App\Http\Controllers\Api\V1\UploadController; use App\Http\Controllers\Api\V1\UserOrganisationTagController; use App\Http\Controllers\Api\V1\Admin\AdminOrganisationController; use App\Http\Controllers\Api\V1\Admin\AdminUserController; @@ -146,6 +147,9 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { Route::delete('auth/trusted-devices/{device}', [TrustedDeviceController::class, 'destroy']); Route::delete('auth/trusted-devices', [TrustedDeviceController::class, 'destroyAll']); + // File uploads + Route::post('upload/image', [UploadController::class, 'uploadImage']); + // Account management (self-service) Route::put('me/profile', [AccountController::class, 'updateProfile']); Route::post('me/change-password', [AccountController::class, 'changePassword']); diff --git a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php index a9466b8c..f8f29d69 100644 --- a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php +++ b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php @@ -317,7 +317,7 @@ class PublicRegistrationDataTest extends TestCase RegistrationFormField::factory()->tagPickerField()->create([ 'event_id' => $event->id, - 'tag_category' => 'Certificaat', + 'tag_categories' => ['Certificaat'], 'is_portal_visible' => true, 'is_admin_only' => false, ]); diff --git a/api/tests/Feature/Api/V1/UploadTest.php b/api/tests/Feature/Api/V1/UploadTest.php new file mode 100644 index 00000000..2fa1d472 --- /dev/null +++ b/api/tests/Feature/Api/V1/UploadTest.php @@ -0,0 +1,125 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->user = User::factory()->create(); + $this->organisation->users()->attach($this->user, ['role' => 'org_admin']); + + Storage::fake('public'); + } + + public function test_upload_image_succeeds_with_valid_png(): void + { + Sanctum::actingAs($this->user); + + $file = UploadedFile::fake()->image('logo.png', 200, 200); + + $response = $this->postJson('/api/v1/upload/image', [ + 'file' => $file, + 'purpose' => 'logo', + ]); + + $response->assertOk() + ->assertJsonStructure(['data' => ['url']]); + + $url = $response->json('data.url'); + $this->assertStringContainsString('uploads/logo', $url); + } + + public function test_upload_image_rejects_unsupported_mime(): void + { + Sanctum::actingAs($this->user); + + $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + + $response = $this->postJson('/api/v1/upload/image', [ + 'file' => $file, + 'purpose' => 'logo', + ]); + + $response->assertStatus(422); + } + + public function test_upload_image_rejects_oversized_file(): void + { + Sanctum::actingAs($this->user); + + $file = UploadedFile::fake()->image('large.png')->size(6000); // 6MB > 5MB limit + + $response = $this->postJson('/api/v1/upload/image', [ + 'file' => $file, + 'purpose' => 'logo', + ]); + + $response->assertStatus(422); + } + + public function test_upload_image_requires_authentication(): void + { + $file = UploadedFile::fake()->image('logo.png', 200, 200); + + $response = $this->postJson('/api/v1/upload/image', [ + 'file' => $file, + 'purpose' => 'logo', + ]); + + $response->assertUnauthorized(); + } + + public function test_upload_image_logs_activity(): void + { + Sanctum::actingAs($this->user); + + $file = UploadedFile::fake()->image('logo.png', 200, 200); + + $this->postJson('/api/v1/upload/image', [ + 'file' => $file, + 'purpose' => 'logo', + ]); + + $this->assertDatabaseHas('activity_log', [ + 'log_name' => 'upload', + 'description' => 'image.uploaded', + 'causer_id' => $this->user->id, + ]); + } + + public function test_upload_image_requires_valid_purpose(): void + { + Sanctum::actingAs($this->user); + + $file = UploadedFile::fake()->image('logo.png', 200, 200); + + $response = $this->postJson('/api/v1/upload/image', [ + 'file' => $file, + 'purpose' => 'invalid', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('purpose'); + } +} diff --git a/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php b/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php index 88c4230b..c35965ec 100644 --- a/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php +++ b/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php @@ -121,32 +121,32 @@ class RegistrationFormFieldTest extends TestCase ->assertJsonValidationErrors('options'); } - public function test_store_tag_picker_accepts_tag_category(): void + public function test_store_tag_picker_accepts_tag_categories(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ 'label' => 'Vaardigheden', 'field_type' => 'tag_picker', - 'tag_category' => 'Vaardigheid', + 'tag_categories' => ['Vaardigheid', 'Horeca'], ]); $response->assertCreated() - ->assertJsonPath('data.tag_category', 'Vaardigheid'); + ->assertJsonPath('data.tag_categories', ['Vaardigheid', 'Horeca']); } - public function test_store_text_field_rejects_tag_category(): void + public function test_store_text_field_rejects_tag_categories(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ 'label' => 'Naam', 'field_type' => 'text', - 'tag_category' => 'Vaardigheid', + 'tag_categories' => ['Vaardigheid'], ]); $response->assertUnprocessable() - ->assertJsonValidationErrors('tag_category'); + ->assertJsonValidationErrors('tag_categories'); } public function test_slug_uniqueness_per_event(): void diff --git a/apps/app/src/components/common/ImageUploadField.vue b/apps/app/src/components/common/ImageUploadField.vue new file mode 100644 index 00000000..618c6686 --- /dev/null +++ b/apps/app/src/components/common/ImageUploadField.vue @@ -0,0 +1,131 @@ + + + diff --git a/apps/app/src/components/event/RegistrationFieldCard.vue b/apps/app/src/components/event/RegistrationFieldCard.vue index c2550ede..2ddc1e74 100644 --- a/apps/app/src/components/event/RegistrationFieldCard.vue +++ b/apps/app/src/components/event/RegistrationFieldCard.vue @@ -107,12 +107,12 @@ function formatOptions(options: NormalizedOption[] | null): string { Opties: {{ formatOptions(field.normalized_options) }} - +
- Categorie: {{ field.tag_category }} + Categorieën: {{ field.tag_categories.join(', ') }}
diff --git a/apps/app/src/components/event/RegistrationFieldFormDialog.vue b/apps/app/src/components/event/RegistrationFieldFormDialog.vue index 762ad639..da62d8ea 100644 --- a/apps/app/src/components/event/RegistrationFieldFormDialog.vue +++ b/apps/app/src/components/event/RegistrationFieldFormDialog.vue @@ -52,7 +52,7 @@ const defaultForm = () => ({ label: '', field_type: 'text' as string, options: [] as OptionEntry[], - tag_category: null as string | null, + tag_categories: [] as string[], is_required: false, is_filterable: false, is_portal_visible: true, @@ -85,7 +85,7 @@ watch(modelValue, (open) => { options: props.field.normalized_options ? props.field.normalized_options.map(o => ({ label: o.label, description: o.description ?? '' })) : [], - tag_category: props.field.tag_category, + tag_categories: props.field.tag_categories ?? [], is_required: props.field.is_required, is_filterable: props.field.is_filterable, is_portal_visible: props.field.is_portal_visible, @@ -142,10 +142,10 @@ function onSubmit() { } if (showTagCategory.value) { - payload.tag_category = form.value.tag_category || null + payload.tag_categories = form.value.tag_categories.length > 0 ? form.value.tag_categories : null } else { - payload.tag_category = null + payload.tag_categories = null } } @@ -242,81 +242,13 @@ defineExpose({ setErrors }) /> - - - -
- - - - - - - - - - - -
- - Optie toevoegen - -

- {{ errors.options }} -

-
- - - - + @@ -332,15 +264,6 @@ defineExpose({ setErrors }) persistent-hint /> - - - @@ -371,6 +294,118 @@ defineExpose({ setErrors }) /> + + + + + + diff --git a/apps/app/src/components/organisation/EmailBrandingTab.vue b/apps/app/src/components/organisation/EmailBrandingTab.vue index d8ed162f..6e530f61 100644 --- a/apps/app/src/components/organisation/EmailBrandingTab.vue +++ b/apps/app/src/components/organisation/EmailBrandingTab.vue @@ -2,6 +2,7 @@ import { VForm } from 'vuetify/components/VForm' import { useEmailSettings, useUpdateEmailSettings } from '@/composables/api/useEmail' import { emailValidator } from '@core/utils/validators' +import ImageUploadField from '@/components/common/ImageUploadField.vue' import type { AxiosError } from 'axios' import type { ApiErrorResponse } from '@/types/auth' @@ -116,34 +117,23 @@ function fieldErrors(field: string): string | undefined { @submit.prevent="onSubmit" > - + - -
- -

- Geen logo ingesteld + {{ fieldErrors('logo_url') }}

diff --git a/apps/app/src/components/organisation/RegistrationFieldTemplatesTab.vue b/apps/app/src/components/organisation/RegistrationFieldTemplatesTab.vue index 13260dd1..d6b0d0f3 100644 --- a/apps/app/src/components/organisation/RegistrationFieldTemplatesTab.vue +++ b/apps/app/src/components/organisation/RegistrationFieldTemplatesTab.vue @@ -68,7 +68,7 @@ const defaultForm = () => ({ label: '', field_type: 'text' as string, options: [] as OptionEntry[], - tag_category: null as string | null, + tag_categories: [] as string[], is_required: false, is_filterable: false, is_portal_visible: true, @@ -113,7 +113,7 @@ function openEditDialog(template: RegistrationFieldTemplate) { options: template.normalized_options ? template.normalized_options.map(o => ({ label: o.label, description: o.description ?? '' })) : [], - tag_category: template.tag_category, + tag_categories: template.tag_categories ?? [], is_required: template.is_required, is_filterable: template.is_filterable, is_portal_visible: template.is_portal_visible, @@ -169,7 +169,7 @@ function onSubmit() { } if (showTagCategory.value) { - payload.tag_category = form.value.tag_category || null + payload.tag_categories = form.value.tag_categories.length > 0 ? form.value.tag_categories : null } } @@ -467,81 +467,13 @@ function activate(template: RegistrationFieldTemplate) { /> - - - -
- - - - - - - - - - - -
- - Optie toevoegen - -

- {{ errors.options }} -

-
- - - - + @@ -571,15 +503,6 @@ function activate(template: RegistrationFieldTemplate) { :error-messages="errors.sort_order" /> - - - @@ -610,6 +533,118 @@ function activate(template: RegistrationFieldTemplate) { /> + + + + + +
diff --git a/apps/app/src/types/registration-field-template.ts b/apps/app/src/types/registration-field-template.ts index a696da2a..3d153d6b 100644 --- a/apps/app/src/types/registration-field-template.ts +++ b/apps/app/src/types/registration-field-template.ts @@ -49,7 +49,7 @@ export interface RegistrationFieldTemplate { field_type: RegistrationFieldType options: FieldOption[] | null normalized_options: NormalizedOption[] | null - tag_category: string | null + tag_categories: string[] | null is_required: boolean is_filterable: boolean is_portal_visible: boolean @@ -65,7 +65,7 @@ export interface RegistrationFieldTemplateCreateDTO { label: string field_type: RegistrationFieldType options?: FieldOption[] | null - tag_category?: string | null + tag_categories?: string[] | null is_required?: boolean is_filterable?: boolean is_portal_visible?: boolean diff --git a/apps/app/src/types/registration-form-field.ts b/apps/app/src/types/registration-form-field.ts index 40d88882..6386bba8 100644 --- a/apps/app/src/types/registration-form-field.ts +++ b/apps/app/src/types/registration-form-field.ts @@ -17,7 +17,7 @@ export interface RegistrationFormField { field_type: RegistrationFieldType options: FieldOption[] | null normalized_options: NormalizedOption[] | null - tag_category: string | null + tag_categories: string[] | null is_required: boolean is_portal_visible: boolean is_admin_only: boolean @@ -34,7 +34,7 @@ export interface RegistrationFormFieldCreateDTO { label: string field_type: RegistrationFieldType options?: FieldOption[] | null - tag_category?: string | null + tag_categories?: string[] | null is_required?: boolean is_portal_visible?: boolean is_admin_only?: boolean diff --git a/apps/portal/src/pages/register/[eventSlug].vue b/apps/portal/src/pages/register/[eventSlug].vue index fbb3120c..40bfecac 100644 --- a/apps/portal/src/pages/register/[eventSlug].vue +++ b/apps/portal/src/pages/register/[eventSlug].vue @@ -263,6 +263,21 @@ function isMultiValueType(t: RegistrationFieldType): boolean { return t === 'multiselect' || t === 'checkbox' || t === 'tag_picker' } +function groupedTagItems(tags: Array<{ id: string; name: string; category: string | null }>) { + const items: Array<{ type?: string; title: string; value?: string }> = [] + let lastCategory: string | null = null + + for (const tag of tags) { + if (tag.category !== lastCategory) { + items.push({ type: 'subheader', title: tag.category ?? 'Overig' }) + lastCategory = tag.category + } + items.push({ title: tag.name, value: tag.id }) + } + + return items +} + function isEmptyFieldValue(value: unknown, type: RegistrationFieldType): boolean { if (value === undefined || value === null) return true if (value === '') return true @@ -1251,7 +1266,7 @@ async function onSubmit() { :error-messages="fieldErrors[field.slug]" /> -