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 @@
+
+
+
+
- {{ errors.options }} -
-+ {{ errors.options }} +
+- Geen logo ingesteld + {{ fieldErrors('logo_url') }}
- {{ errors.options }} -
-+ {{ errors.options }} +
+