Files
crewli/api/database/seeders/FormBuilderDevSeeder.php
bert.hausmans 079d10975b refactor(form-builder): strict validator + drop form_fields.conditional_logic JSON column
WS-5c commit 3 of 4. FormRequests (Store/Update) now reject bad
conditional_logic trees at the HTTP boundary — the `after()` hook
unwraps the `show_when` envelope, normalises legacy `{all|any: [...]}`
group shape to the service's internal form, and delegates to
`FormFieldConditionalLogicService::assertSpecsValid()`. Unknown
operators, root conditions, empty groups, and unknown field_slug
references produce a 422 with a readable error before any write.

`form_fields.conditional_logic` JSON column dropped. FormField model
`$fillable` and `$casts` no longer mention the column; factory default
no longer writes `null` to it. Snapshot fixtures in the dev seeder and
the legacy-forms migration command keep `conditional_logic` in their
snapshot JSON shape — that's the schema_snapshot contract, not the DB
column.

FormFieldController now maps InvalidConditionalLogicSpecException to
422 alongside FrozenSchemaException / CyclicDependencyException.

Rollback path: roll back WS-5c commits 1–3 together. Partial rollback
(drop-column reversed but backfill still applied) is not a supported
state — matching the WS-5a/b precedent on the family's full-rollback
contract.

Tests: 6 new (strict FormRequest rejection cases + JSON-column drop
assertion). Rollback step counts in WS-5a/b migration tests bumped +1
for the drop_conditional_logic_json_column migration. Baseline
1142 → 1148 green (3085 → 3099 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:03:21 +02:00

585 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Enums\FormBuilder\FormValueStorageHint;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormTemplate;
use App\Models\FormBuilder\FormValue;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\PersonTag;
use App\Models\UserOrganisationTag;
use App\Services\FormBuilder\FormSubmissionService;
use App\Services\FormBuilder\FormValueService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Seeds the form-builder layer for DevSeeder: 16 system form_templates per
* org, one FormSchema per event, the canonical 16-field registration set,
* and a realistic batch of FormSubmissions for approved/applied/no_show
* persons (same rule as the forms:migrate-legacy-data command).
*
* Callers MUST wrap this in ActivityLog::suppressed() — every field create
* would otherwise fire a logFieldChange("created") entry.
*/
final class FormBuilderDevSeeder
{
/**
* 16 canonical registration fields mirroring the legacy system templates.
* Used by both seedSystemTemplates() and seedEventSchema() so the shape
* stays aligned.
*
* @return list<array<string, mixed>>
*/
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, // snapshot shape: null for fields without conditional logic
'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<string, FormField> $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<array<string, mixed>>
*/
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',
// No config_type = tag_categories row → FormFieldResource
// emits every active person_tag for the org (no category
// filter). See ARCH-FORM-BUILDER §17.5.
'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 (hard cap of 5 is in FormValueService shape
// validation). Post-WS-5b the UI cap lives as a relational
// row with rule_type `max_selected` — see
// seedEventRegistrationShowcaseSchema() which populates it
// via FormFieldValidationRuleService.
'max_selected_ui_cap' => 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,
]);
$ruleService = app(\App\Services\FormBuilder\FormFieldValidationRuleService::class);
foreach (self::showcaseFieldDefinitions() as $sortOrder => $def) {
$field = 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,
'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,
]);
// Relational validation rules (WS-5b). The SECTION_PRIORITY
// field carries the UI soft cap as a `max_selected` row; other
// fields in the showcase have no rules yet.
if (isset($def['max_selected_ui_cap'])) {
$ruleService->replaceRules($field, [[
'rule_type' => \App\Enums\FormBuilder\FormFieldValidationRuleType::MaxSelected->value,
'parameters' => ['value' => (int) $def['max_selected_ui_cap']],
]]);
}
}
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;
}
}