diff --git a/api/app/Observers/UserObserver.php b/api/app/Observers/UserObserver.php new file mode 100644 index 00000000..d49ca296 --- /dev/null +++ b/api/app/Observers/UserObserver.php @@ -0,0 +1,24 @@ + $user->id]); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 73747c15..027e81b6 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -38,6 +38,7 @@ use App\Models\FormBuilder\FormValue; use App\Models\VolunteerAvailability; use App\Observers\FormBuilder\FormValueObserver; use App\Observers\PersonObserver; +use App\Observers\UserObserver; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; @@ -97,6 +98,7 @@ class AppServiceProvider extends ServiceProvider ]); Person::observe(PersonObserver::class); + User::observe(UserObserver::class); FormValue::observe(FormValueObserver::class); ResetPassword::createUrlUsing(function ($user, string $token) { diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 2433ab4c..a07dc1db 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -20,6 +20,7 @@ use App\Models\TimeSlot; use App\Models\User; use App\Models\UserOrganisationTag; use App\Models\VolunteerAvailability; +use App\Support\ActivityLog; use Illuminate\Database\Seeder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -45,12 +46,17 @@ class DevSeeder extends Seeder { $this->call(RoleSeeder::class); - $this->seedOrganisation(); - $this->seedEchtFeesten(); - $this->seedBraderie(); - $this->seedIJsbaan(); - $this->seedKoningsdag(); - $this->seedNachtVanDeKaap(); + // Suppress all activity-log writes during bulk fixture generation. + // Hundreds of logSchemaChange/logFieldChange + Organisation-trait + // entries would otherwise flood activity_log without useful value. + ActivityLog::suppressed(function (): void { + $this->seedOrganisation(); + $this->seedEchtFeesten(); + $this->seedBraderie(); + $this->seedIJsbaan(); + $this->seedKoningsdag(); + $this->seedNachtVanDeKaap(); + }); } // ========================================================================= @@ -152,11 +158,11 @@ class DevSeeder extends Seeder $this->personTags[$data['name']] = $tag; } - // ── Registration Field Templates (system defaults) ── + // ── Form Builder: system templates (new structure) ── - \App\Services\RegistrationFieldTemplateService::seedSystemTemplates($this->org); + $templatesCreated = FormBuilderDevSeeder::seedSystemTemplates($this->org); - $this->command->info(' Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, 16 registration templates created'); + $this->command->info(" Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, {$templatesCreated} form_templates created"); }); } @@ -916,6 +922,10 @@ class DevSeeder extends Seeder ]); } + $formSchema = FormBuilderDevSeeder::seedEventSchema($festival); + $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($festival, $formSchema); + $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + $this->command->info(' Echt Feesten 2026 complete'); }); } @@ -978,6 +988,10 @@ class DevSeeder extends Seeder $this->linkUsersToApprovedPersons($braderie); + $formSchema = FormBuilderDevSeeder::seedEventSchema($braderie); + $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($braderie, $formSchema); + $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + $this->command->info(' Braderie Dorpstown 2026 complete'); }); } @@ -1149,6 +1163,10 @@ class DevSeeder extends Seeder $personCount = Person::where('event_id', $ijsbaan->id)->count(); $this->command->info(" {$personCount} persons, " . count($allShifts) . ' shifts created'); + + $formSchema = FormBuilderDevSeeder::seedEventSchema($ijsbaan); + $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($ijsbaan, $formSchema); + $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); }); } @@ -1317,6 +1335,10 @@ class DevSeeder extends Seeder $personCount = Person::where('event_id', $koningsdag->id)->count(); $assignCount = ShiftAssignment::whereIn('shift_id', collect($kShifts)->pluck('id'))->count(); $this->command->info(" {$personCount} persons, 12 shifts, {$assignCount} assignments"); + + $formSchema = FormBuilderDevSeeder::seedEventSchema($koningsdag); + $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($koningsdag, $formSchema); + $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); }); } @@ -1347,7 +1369,9 @@ class DevSeeder extends Seeder TimeSlot::create(['event_id' => $event->id, 'name' => 'Nacht', 'person_type' => 'VOLUNTEER', 'date' => '2026-09-12', 'start_time' => '20:00', 'end_time' => '04:00', 'duration_hours' => 8.00]); - $this->command->info(' Empty draft event created'); + FormBuilderDevSeeder::seedEventSchema($event); + + $this->command->info(' Empty draft event created (with form schema, 0 submissions)'); }); } diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php new file mode 100644 index 00000000..e5a614cb --- /dev/null +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -0,0 +1,231 @@ +> + */ + public static function canonicalFields(): array + { + return [ + ['type' => FormFieldType::HEADING, 'slug' => 'persoonlijke-voorkeuren', 'label' => 'Persoonlijke voorkeuren', 'help_text' => 'Vertel ons wat we over jou moeten weten', 'display_width' => 'full'], + ['type' => FormFieldType::SELECT, 'slug' => 'shirtmaat', 'label' => 'Shirtmaat', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, 'display_width' => 'half'], + ['type' => FormFieldType::MULTISELECT, 'slug' => 'dieetwensen', 'label' => 'Dieetwensen', 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', "Geen pinda's", 'Geen noten'], 'is_filterable' => true, 'display_width' => 'half'], + ['type' => FormFieldType::HEADING, 'slug' => 'vergoeding', 'label' => 'Vergoeding', 'help_text' => 'Kies hoe je wilt worden bedankt voor je inzet', 'display_width' => 'full'], + ['type' => FormFieldType::RADIO, 'slug' => 'vergoedingstype', 'label' => 'Vergoedingstype', 'options' => [ + ['label' => 'Pro Deo', 'description' => 'Je werkt als vrijwilliger zonder financiële vergoeding'], + ['label' => 'Entreeticket', 'description' => 'Je ontvangt een gratis festivalticket als dank voor je inzet'], + ['label' => 'Vrijwilligersvergoeding', 'description' => 'Je ontvangt een vergoeding conform de vrijwilligersregeling'], + ], 'is_required' => true, 'display_width' => 'full'], + ['type' => FormFieldType::HEADING, 'slug' => 'noodcontact', 'label' => 'Noodcontact', 'help_text' => 'Wie kunnen we bereiken bij calamiteiten?', 'display_width' => 'full'], + ['type' => FormFieldType::TEXT, 'slug' => 'noodcontact-naam', 'label' => 'Noodcontact naam', 'is_pii' => true, 'display_width' => 'half'], + ['type' => FormFieldType::TEXT, 'slug' => 'noodcontact-telefoon', 'label' => 'Noodcontact telefoon', 'is_pii' => true, 'display_width' => 'half'], + ['type' => FormFieldType::HEADING, 'slug' => 'ervaring-vaardigheden', 'label' => 'Ervaring & vaardigheden', 'help_text' => "Welke diploma's en skills heb je?", 'display_width' => 'full'], + ['type' => FormFieldType::BOOLEAN, 'slug' => 'ehbo-bhv-diploma', 'label' => 'EHBO / BHV diploma', 'is_filterable' => true, 'display_width' => 'half'], + ['type' => FormFieldType::BOOLEAN, 'slug' => 'rijbewijs', 'label' => 'Rijbewijs', 'is_filterable' => true, 'display_width' => 'half'], + ['type' => FormFieldType::BOOLEAN, 'slug' => 'eerder-vrijwilliger-geweest', 'label' => 'Eerder vrijwilliger geweest', 'is_filterable' => true, 'display_width' => 'half'], + ['type' => FormFieldType::TAG_PICKER, 'slug' => 'certificaten-vaardigheden', 'label' => 'Certificaten & vaardigheden', 'is_filterable' => true, 'display_width' => 'full'], + ['type' => FormFieldType::HEADING, 'slug' => 'aanvullende-informatie', 'label' => 'Aanvullende informatie', 'display_width' => 'full'], + ['type' => FormFieldType::BOOLEAN, 'slug' => 'toestemming-gegevensverwerking', 'label' => 'Toestemming gegevensverwerking', '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).', 'is_required' => true, 'display_width' => 'full'], + ['type' => FormFieldType::TEXTAREA, 'slug' => 'opmerkingen', 'label' => 'Opmerkingen', 'display_width' => 'full'], + ]; + } + + public static function seedSystemTemplates(Organisation $org): int + { + $count = 0; + foreach (self::canonicalFields() as $sortOrder => $field) { + $snapshot = [ + 'schema_version' => 1, + 'snapshot_created_at' => now()->toIso8601String(), + 'schema' => [ + 'name' => $field['label'].' (template)', + 'slug' => $field['slug'], + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + 'description' => null, + 'locale' => 'nl', + 'freeze_on_submit' => false, + 'section_level_submit' => false, + 'settings' => [], + ], + 'sections' => [], + 'fields' => [[ + 'id' => (string) Str::ulid(), + 'slug' => $field['slug'], + 'field_type' => $field['type']->value, + 'label' => $field['label'], + 'help_text' => $field['help_text'] ?? null, + 'section_slug' => null, + 'options' => $field['options'] ?? null, + 'validation_rules' => null, + 'is_required' => $field['is_required'] ?? false, + 'is_filterable' => $field['is_filterable'] ?? false, + 'is_pii' => $field['is_pii'] ?? false, + 'binding' => null, + 'conditional_logic' => null, + 'translations' => null, + 'value_storage_hint' => $field['type']->recommendedValueStorageHint()->value, + 'sort_order' => $sortOrder + 1, + ]], + ]; + + FormTemplate::create([ + 'organisation_id' => $org->id, + 'name' => $field['label'], + 'slug' => $field['slug'], + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'description' => null, + 'schema_snapshot' => $snapshot, + 'is_active' => true, + ])->forceFill(['is_system' => true])->save(); + $count++; + } + + return $count; + } + + /** + * Create one FormSchema (event_registration) for this event with the + * canonical 16-field set. + */ + public static function seedEventSchema(Event $event): FormSchema + { + $schema = FormSchema::create([ + 'organisation_id' => $event->organisation_id, + 'owner_type' => 'event', + 'owner_id' => $event->id, + 'name' => $event->name.' — registratie', + 'slug' => Str::slug($event->slug.'-registratie'), + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'description' => "Registratieformulier voor {$event->name}.", + 'is_published' => in_array( + $event->status, + ['registration_open', 'buildup', 'showday', 'teardown', 'closed'], + true + ), + 'submission_mode' => FormSubmissionMode::DRAFT_SINGLE, + 'locale' => 'nl', + 'snapshot_mode' => FormSchemaSnapshotMode::NEVER, + 'freeze_on_submit' => false, + 'section_level_submit' => false, + 'auto_save_enabled' => false, + ]); + + foreach (self::canonicalFields() as $sortOrder => $field) { + FormField::create([ + 'form_schema_id' => $schema->id, + 'field_type' => $field['type']->value, + 'slug' => $field['slug'], + 'label' => $field['label'], + 'help_text' => $field['help_text'] ?? null, + 'options' => $field['options'] ?? null, + 'is_required' => $field['is_required'] ?? false, + 'is_filterable' => $field['is_filterable'] ?? false, + 'is_portal_visible' => true, + 'is_admin_only' => false, + 'is_pii' => $field['is_pii'] ?? false, + 'display_width' => $field['display_width'] ?? 'full', + 'value_storage_hint' => $field['type']->recommendedValueStorageHint(), + 'sort_order' => $sortOrder + 1, + ]); + } + + return $schema; + } + + /** + * For each person with status ∈ applied/approved/no_show on this event, + * create one FormSubmission with a realistic handful of FormValues. + * Status follows the same rule as MigrateLegacyFormsData. + */ + public static function seedSubmissionsForEvent(Event $event, FormSchema $schema): int + { + $fields = $schema->fields()->get()->keyBy('slug'); + $persons = Person::where('event_id', $event->id) + ->whereIn('status', ['applied', 'approved', 'no_show']) + ->get(); + + $count = 0; + foreach ($persons as $person) { + $isSubmittedStatus = in_array($person->status, ['applied', 'approved', 'no_show'], true); + $status = $isSubmittedStatus ? FormSubmissionStatus::SUBMITTED : FormSubmissionStatus::DRAFT; + + $submission = FormSubmission::create([ + 'form_schema_id' => $schema->id, + 'subject_type' => 'person', + 'subject_id' => $person->id, + 'submitted_by_user_id' => $person->user_id, + 'status' => $status, + 'is_test' => false, + 'submitted_in_locale' => 'nl', + 'submitted_at' => $status === FormSubmissionStatus::SUBMITTED ? now()->subDays(rand(1, 30)) : null, + 'schema_version_at_submit' => $status === FormSubmissionStatus::SUBMITTED ? 1 : null, + ]); + + $seed = crc32((string) $person->id); + $sizes = ['XS', 'S', 'M', 'L', 'XL', 'XXL']; + self::createValueIfField($fields, 'shirtmaat', ['value' => $sizes[$seed % 6]], $submission); + self::createValueIfField($fields, 'dieetwensen', ($seed % 3 === 0) ? ['Vegetarisch'] : [], $submission); + self::createValueIfField($fields, 'rijbewijs', ['value' => ($seed % 2) === 0], $submission); + self::createValueIfField($fields, 'ehbo-bhv-diploma', ['value' => ($seed % 4) === 0], $submission); + self::createValueIfField($fields, 'noodcontact-naam', ['value' => 'Partner / Familielid'], $submission); + self::createValueIfField($fields, 'toestemming-gegevensverwerking', ['value' => true], $submission); + + $count++; + } + + return $count; + } + + /** + * @param \Illuminate\Support\Collection $fields + * @param mixed $value + */ + private static function createValueIfField($fields, string $slug, $value, FormSubmission $submission): void + { + $field = $fields->get($slug); + if ($field === null) { + return; + } + + FormValue::create([ + 'form_submission_id' => $submission->id, + 'form_field_id' => $field->id, + 'value' => $value, + ]); + } +}