feat: registration form fields, section preferences, tag sync & schema updates

Implement EAV system for dynamic event-specific registration fields
with organisation-level templates, person section preferences with
priority ranking, and TagSyncService for deferred tag_picker sync.

New tables: registration_field_templates, registration_form_fields,
person_field_values, person_section_preferences.
New columns: persons.remarks, events.registration_show_section_preferences,
events.registration_show_availability.

58 tests, 126 assertions — all 432 tests pass (zero regressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 22:10:16 +02:00
parent fcff3b0344
commit f6e3568011
51 changed files with 3774 additions and 1 deletions

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\RegistrationFieldType;
use App\Models\Organisation;
use App\Models\RegistrationFieldTemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<RegistrationFieldTemplate> */
final class RegistrationFieldTemplateFactory extends Factory
{
protected $model = RegistrationFieldTemplate::class;
/** @return array<string, mixed> */
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,
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\RegistrationFieldType;
use App\Models\Event;
use App\Models\RegistrationFormField;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<RegistrationFormField> */
final class RegistrationFormFieldFactory extends Factory
{
protected $model = RegistrationFormField::class;
/** @return array<string, mixed> */
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,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->text('remarks')->nullable()->after('admin_notes');
});
}
public function down(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->dropColumn('remarks');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->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',
]);
});
}
};

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('registration_field_templates', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('registration_form_fields', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('person_field_values', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('person_section_preferences', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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');
});
}