> */ 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, // Pattern B — snapshot embeds null for form-owned fields. '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, ]); } // ========================================================================= // Sprint 0.5 — event_registration showcase (FORM-02 / §31.10 demo) // // Creates ONE dedicated, public-token-enabled event_registration schema // per dev org, with a curated 5-field set, a draft submission, and a // fully-submitted submission whose TAG_PICKER values exercise the // SyncTagPickerSelectionsOnSubmit listener so user_organisation_tags // populate during `migrate:fresh --seed`. // ========================================================================= /** * Showcase field definitions. 5 fields per Sprint 0.5 spec. * * @return list> */ private static function showcaseFieldDefinitions(): array { return [ [ 'type' => FormFieldType::HEADING, 'slug' => 'over-jou', 'label' => 'Over jou', 'is_required' => false, 'is_filterable' => false, 'display_width' => 'full', 'value_storage_hint' => FormValueStorageHint::JSON, ], [ 'type' => FormFieldType::SELECT, 'slug' => 'shirtmaat', 'label' => 'Shirtmaat', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL'], 'is_required' => true, 'is_filterable' => true, 'display_width' => 'half', 'value_storage_hint' => FormValueStorageHint::STRING, ], [ 'type' => FormFieldType::CHECKBOX_LIST, 'slug' => 'dieetwensen', 'label' => 'Dieetwensen', 'options' => ['Vegetarisch', 'Veganistisch', 'Glutenvrij', 'Lactosevrij', 'Halal', 'Kosher'], 'is_required' => false, 'is_filterable' => true, 'display_width' => 'half', 'value_storage_hint' => FormValueStorageHint::JSON, ], [ 'type' => FormFieldType::TAG_PICKER, 'slug' => 'vaardigheden', 'label' => 'Vaardigheden en certificaten', // validation_rules.tag_categories = [] means "all active // person_tags for this org" — the FormFieldResource picks // up every active tag when no category filter is set. 'validation_rules' => null, 'is_required' => false, 'is_filterable' => true, 'display_width' => 'full', 'value_storage_hint' => FormValueStorageHint::JSON, ], [ 'type' => FormFieldType::AVAILABILITY_PICKER, 'slug' => 'beschikbaarheid', 'label' => 'Wanneer ben je beschikbaar?', 'help_text' => 'Vink alle dagdelen aan waarop je kunt werken.', 'is_required' => true, 'is_filterable' => false, 'display_width' => 'full', 'value_storage_hint' => FormValueStorageHint::JSON, ], [ 'type' => FormFieldType::SECTION_PRIORITY, 'slug' => 'sectie_voorkeur', 'label' => 'Bij welke sectie wil je het liefst werken?', 'help_text' => 'Sleep je voorkeuren in volgorde. Nummer 1 is je eerste keuze.', // UI soft cap; the hard cap of 5 lives in // FormValueService shape validation. 'validation_rules' => ['max_priorities' => 3], 'is_required' => false, 'is_filterable' => false, 'display_width' => 'full', 'value_storage_hint' => FormValueStorageHint::JSON, ], [ 'type' => FormFieldType::TEXTAREA, 'slug' => 'opmerkingen', 'label' => 'Opmerkingen', 'is_required' => false, 'is_filterable' => false, 'display_width' => 'full', 'value_storage_hint' => FormValueStorageHint::STRING, ], ]; } /** * Create the public-token-enabled showcase schema on the given primary * event, plus its 5 fields. Returns the schema for follow-up seeding. */ public static function seedEventRegistrationShowcaseSchema(Organisation $org, Event $event): FormSchema { $name = 'Vrijwilligersregistratie '.$event->name; $slug = self::uniqueSchemaSlug($org, Str::slug($name)); /** @var FormSchema $schema */ $schema = FormSchema::create([ 'organisation_id' => $org->id, 'owner_type' => 'event', 'owner_id' => $event->id, 'name' => $name, 'slug' => $slug, 'purpose' => FormPurpose::EVENT_REGISTRATION, 'description' => "Demo-formulier voor het end-to-end doorlopen van de vrijwilligersregistratie voor {$event->name}.", 'is_published' => true, 'submission_mode' => FormSubmissionMode::DRAFT_SINGLE, 'public_token' => (string) Str::ulid(), 'locale' => 'nl', 'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT, 'freeze_on_submit' => false, 'section_level_submit' => false, 'auto_save_enabled' => false, 'version' => 1, ]); foreach (self::showcaseFieldDefinitions() as $sortOrder => $def) { FormField::create([ 'form_schema_id' => $schema->id, 'field_type' => $def['type']->value, 'slug' => $def['slug'], 'label' => $def['label'], 'help_text' => $def['help_text'] ?? null, 'options' => $def['options'] ?? null, 'validation_rules' => $def['validation_rules'] ?? null, 'is_required' => $def['is_required'] ?? false, 'is_filterable' => $def['is_filterable'] ?? false, 'is_portal_visible' => true, 'is_admin_only' => false, 'is_pii' => false, 'display_width' => $def['display_width'] ?? 'full', 'role_restrictions' => null, 'value_storage_hint' => $def['value_storage_hint'] ?? FormValueStorageHint::JSON, 'sort_order' => $sortOrder + 1, ]); } return $schema->refresh(); } /** * Seed one draft + one fully-submitted submission on the showcase * schema. The submitted one triggers FormSubmissionSubmitted so the * §31.10 listener runs and `user_organisation_tags` picks up * `source=self_reported` rows for the submitter's user. * * Returns summary stats for caller-side logging. * * @return array{draft_person: ?Person, submitted_person: ?Person, synced_tag_count: int} */ public static function seedEventRegistrationShowcaseSubmissions(FormSchema $schema, Organisation $org): array { $event = Event::withoutGlobalScopes()->find($schema->owner_id); if ($event === null) { return ['draft_person' => null, 'submitted_person' => null, 'synced_tag_count' => 0]; } // Deterministic pick: first two approved persons with a user_id // on this event, ordered by ULID (chronological insertion order). $candidates = Person::withoutGlobalScopes() ->where('event_id', $event->id) ->whereNotNull('user_id') ->orderBy('id') ->take(2) ->get(); $draftPerson = $candidates->first(); $submittedPerson = $candidates->skip(1)->first(); if ($draftPerson !== null) { self::buildDraftSubmission($schema, $draftPerson); } $syncedTagCount = 0; if ($submittedPerson !== null) { $syncedTagCount = self::buildSubmittedSubmission($schema, $org, $submittedPerson); } return [ 'draft_person' => $draftPerson, 'submitted_person' => $submittedPerson, 'synced_tag_count' => $syncedTagCount, ]; } /** * Orchestration helper: schema + submissions + console output, matching * the exact format Bert asked for in Sprint 0.5. */ public static function seedEventRegistrationShowcase( Organisation $org, Event $event, ?Command $command = null, ): FormSchema { $schema = self::seedEventRegistrationShowcaseSchema($org, $event); $appUrl = rtrim((string) config('app.url'), '/'); $publicUrl = "{$appUrl}/api/v1/public/forms/{$schema->public_token}"; if ($command !== null) { $command->info("[FormBuilderDevSeeder] Seeded event_registration schema for {$org->name}:"); $command->info(" public URL: {$publicUrl}"); } $stats = self::seedEventRegistrationShowcaseSubmissions($schema, $org); if ($command !== null) { $draft = $stats['draft_person']; if ($draft === null) { $command->warn('[FormBuilderDevSeeder] No person with user_id found — draft submission skipped.'); } else { $command->info("[FormBuilderDevSeeder] Draft registration stored for {$draft->full_name} (person {$draft->id})"); } $submitted = $stats['submitted_person']; if ($submitted === null) { $command->warn('[FormBuilderDevSeeder] Not enough persons with user_id for submitted demo — §31.10 sync not exercised.'); } else { $command->info("[FormBuilderDevSeeder] Submitted registration for {$submitted->full_name}"); $command->info(" TAG_PICKER sync result: {$stats['synced_tag_count']} self_reported tags now on user {$submitted->user_id}"); } } return $schema; } private static function buildDraftSubmission(FormSchema $schema, Person $person): FormSubmission { $fields = $schema->fields()->get()->keyBy('slug'); /** @var FormSubmission $submission */ $submission = FormSubmission::create([ 'form_schema_id' => $schema->id, 'subject_type' => 'person', 'subject_id' => $person->id, 'submitted_by_user_id' => $person->user_id, 'status' => FormSubmissionStatus::DRAFT->value, 'is_test' => false, 'submitted_in_locale' => 'nl', 'opened_at' => now()->subDay(), ]); // Realistic partial fill — deliberately skip TAG_PICKER (that path // is exercised by the submitted demo below). self::createValueIfField($fields, 'shirtmaat', 'M', $submission); self::createValueIfField($fields, 'dieetwensen', ['Vegetarisch'], $submission); return $submission->refresh(); } /** * Full-submit path: create draft, fill values (incl. TAG_PICKER), run * the service so FormSubmissionSubmitted fires and §31.10 runs. Queue * connection is flipped to sync for the duration so the listener * executes inline rather than being deferred to redis during seeding. */ private static function buildSubmittedSubmission(FormSchema $schema, Organisation $org, Person $person): int { $tagIds = PersonTag::withoutGlobalScopes() ->where('organisation_id', $org->id) ->where('is_active', true) ->orderBy('sort_order') ->orderBy('name') ->take(3) ->pluck('id') ->all(); /** @var FormSubmissionService $submissionService */ $submissionService = app(FormSubmissionService::class); $submission = $submissionService->createDraft( $schema, $person, $person->user, [ 'opened_at' => now()->subHours(2), 'is_test' => false, ], ); /** @var FormValueService $valueService */ $valueService = app(FormValueService::class); $valueService->upsertMany( $submission, [ 'shirtmaat' => 'L', 'dieetwensen' => ['Glutenvrij', 'Lactosevrij'], 'vaardigheden' => $tagIds, 'opmerkingen' => 'Kan eerder als nodig.', ], $person->user, ); // Force synchronous listener execution while we're in the seeder // so user_organisation_tags is populated by the time the migrate // command returns (see `QUEUE_CONNECTION=redis` in local .env). $previousConnection = config('queue.default'); config(['queue.default' => 'sync']); try { $submissionService->submit($submission->refresh(), $person->user); } finally { config(['queue.default' => $previousConnection]); } return UserOrganisationTag::query() ->where('user_id', $person->user_id) ->where('organisation_id', $org->id) ->where('source', 'self_reported') ->count(); } private static function uniqueSchemaSlug(Organisation $org, string $base): string { $candidate = $base; $i = 2; while (FormSchema::withoutGlobalScopes() ->where('organisation_id', $org->id) ->where('slug', $candidate) ->exists() ) { $candidate = $base.'-'.$i; $i++; } return $candidate; } }