From e7c9482474f7d01c65523bef8cbdc45372ee71a3 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 03:00:20 +0200 Subject: [PATCH] refactor(form-field): drop form_fields.options + form_field_library.options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final WS-5d cleanup. The JSON columns that have been unread since commit 3 are now physically dropped on both source tables. Their canonical rich-shape lives in form_field_options, accessed exclusively through the morphMany relation. Defensive sweep: any lingering translations.{locale}.options key in either source table's translations bag is stripped. Commit 2's backfill should already have done so exhaustively; this is belt-and-braces. Rollback re-creates the columns as nullable JSON but leaves them empty. Pair with commit 2's rollback to restore the pre-WS-5d data shape on every owner row. The commit-3 getOptionsAttribute accessor-bridge on FormField + FormFieldLibrary is removed — Eloquent's getAttribute() resolution now naturally falls through to the morphMany relation since there's no underlying column to shadow it. New regression test FormFieldOptionsAccessTest asserts $field->options resolves to an Eloquent Collection of FormFieldOption instances and lazy-loads in exactly 2 queries (1 parent + 1 lazy-load options) on a fresh fetch without with() preload. Same trio for FormFieldLibrary. Migration step-count tests in WS-5a/b/c bumped by 1 to account for the new drop_form_field_options_json_columns migration on the rollback stack. Documentation: - SCHEMA.md v2.6: form_field_options table documented; options row removed from form_fields and form_field_library; morphMany relations updated; cross-references to ARCH-FORM-BUILDER §17.6 and addendum §Q3 WS-5d Uitvoering added on both source-table docblocks. - ARCH-FORM-BUILDER.md v1.8: new §17.6 "Field options (relational)" mirrors the §17.4 / §17.5 relational-sibling structure with sub-sections 17.6.1 rationale, 17.6.2 table + catalogue, 17.6.3 service / scope / cascade / activity log, 17.6.4 snapshot embedding, 17.6.5 external API contract. Existing Webhooks section renumbered from §17.6 to §17.7. - ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md: "Uitvoering — WS-5d (2026-04-27)" section added. Eight paragraphs covering the snapshot atomic rewrite, strict-fail backfill dispatch, dual activity-log emit, four-sibling base-class extraction warrant, commit 0 dead-code precondition, the temporary getOptionsAttribute accessor-bridge pattern (with reusability note for future JSON→relational refactors), the dev-seeder vergoedingstype RADIO normalisation (drift correction explicitly distinguished from the parallel apps/app RegistrationFieldTemplate description domain), and the WS-5 family completion note. - BACKLOG.md: FORM-BUILDER-LIBRARY-AUDIT-LOG entry extended to four services (adds library.options_replaced); new FORM-BUILDER-MORPH-SCOPE-BASE-CLASS entry added as the WS-5d follow-up now that all four concrete morph-scope siblings exist. Tests: 1193 → 1208 green (+15 across commits 3+4+5; this commit alone: +2 from the regression test). This completes the WS-5 family. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/app/Models/FormBuilder/FormField.php | 22 --- .../Models/FormBuilder/FormFieldLibrary.php | 20 --- ...2_drop_form_field_options_json_columns.php | 87 ++++++++++ .../FormFieldBindingMigrationTest.php | 23 +-- .../ConditionalLogicBackfillTest.php | 16 +- .../FormFieldOptionsAccessTest.php | 110 +++++++++++++ .../Options/FormFieldOptionsBackfillTest.php | 20 +-- .../FormFieldValidationRuleBackfillTest.php | 20 +-- .../ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md | 20 +++ dev-docs/ARCH-FORM-BUILDER.md | 154 ++++++++++++++++-- dev-docs/BACKLOG.md | 17 +- dev-docs/SCHEMA.md | 68 +++++++- 12 files changed, 475 insertions(+), 102 deletions(-) create mode 100644 api/database/migrations/2026_04_27_100002_drop_form_field_options_json_columns.php create mode 100644 api/tests/Feature/FormBuilder/FormFieldOptionsAccessTest.php diff --git a/api/app/Models/FormBuilder/FormField.php b/api/app/Models/FormBuilder/FormField.php index 16c131ac..477addd4 100644 --- a/api/app/Models/FormBuilder/FormField.php +++ b/api/app/Models/FormBuilder/FormField.php @@ -121,28 +121,6 @@ final class FormField extends Model ->orderBy('sort_order'); } - /** - * Force `$field->options` (no parens) to resolve to the morphMany - * relation rather than the legacy JSON column attribute that lives - * on the table until WS-5d commit 5 drops it. Required because - * Eloquent's getAttribute() prefers the underlying column over a - * relation method when the column still exists. Removed at commit 5 - * along with the column. - * - * @return \Illuminate\Database\Eloquent\Collection - */ - public function getOptionsAttribute(): \Illuminate\Database\Eloquent\Collection - { - if (! $this->relationLoaded('options')) { - $this->load('options'); - } - - /** @var \Illuminate\Database\Eloquent\Collection $relation */ - $relation = $this->getRelation('options'); - - return $relation; - } - public function conditionalLogicGroups(): HasMany { return $this->hasMany(FormFieldConditionalLogicGroup::class, 'form_field_id'); diff --git a/api/app/Models/FormBuilder/FormFieldLibrary.php b/api/app/Models/FormBuilder/FormFieldLibrary.php index 34a05832..a2a01d79 100644 --- a/api/app/Models/FormBuilder/FormFieldLibrary.php +++ b/api/app/Models/FormBuilder/FormFieldLibrary.php @@ -81,24 +81,4 @@ final class FormFieldLibrary extends Model return $this->morphMany(FormFieldOption::class, 'owner') ->orderBy('sort_order'); } - - /** - * Force `$library->options` (no parens) to resolve to the morphMany - * relation rather than the legacy JSON column attribute that lives - * on the table until WS-5d commit 5 drops it. Removed at commit 5 - * along with the column. - * - * @return \Illuminate\Database\Eloquent\Collection - */ - public function getOptionsAttribute(): \Illuminate\Database\Eloquent\Collection - { - if (! $this->relationLoaded('options')) { - $this->load('options'); - } - - /** @var \Illuminate\Database\Eloquent\Collection $relation */ - $relation = $this->getRelation('options'); - - return $relation; - } } diff --git a/api/database/migrations/2026_04_27_100002_drop_form_field_options_json_columns.php b/api/database/migrations/2026_04_27_100002_drop_form_field_options_json_columns.php new file mode 100644 index 00000000..7d3c665a --- /dev/null +++ b/api/database/migrations/2026_04_27_100002_drop_form_field_options_json_columns.php @@ -0,0 +1,87 @@ +stripResidualTranslationsOptionsKeys('form_fields'); + $this->stripResidualTranslationsOptionsKeys('form_field_library'); + + Schema::table('form_fields', function (Blueprint $table): void { + $table->dropColumn('options'); + }); + Schema::table('form_field_library', function (Blueprint $table): void { + $table->dropColumn('options'); + }); + } + + public function down(): void + { + Schema::table('form_fields', function (Blueprint $table): void { + $table->json('options')->nullable()->after('section'); + }); + Schema::table('form_field_library', function (Blueprint $table): void { + $table->json('options')->nullable()->after('help_text'); + }); + } + + /** + * Defensive sweep — walk every row's translations bag and strip + * `{locale}.options` keys. After WS-5d commit 2 this should be a + * no-op on every row. + */ + private function stripResidualTranslationsOptionsKeys(string $table): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'translations')) { + return; + } + + $rows = DB::table($table) + ->whereNotNull('translations') + ->get(['id', 'translations']); + + foreach ($rows as $row) { + $bag = json_decode((string) $row->translations, true); + if (! is_array($bag)) { + continue; + } + $changed = false; + foreach ($bag as $locale => $localeBag) { + if (is_array($localeBag) && array_key_exists('options', $localeBag)) { + unset($bag[$locale]['options']); + if ($bag[$locale] === []) { + unset($bag[$locale]); + } + $changed = true; + } + } + if ($changed) { + DB::table($table)->where('id', $row->id)->update([ + 'translations' => $bag === [] ? null : json_encode($bag), + ]); + } + } + } +}; diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index abd87715..9850bca5 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,14 +33,15 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_forward_migrations_backfill_rows_from_both_json_sources(): void { - // Roll back to pre-WS-5a state: 2 WS-5d migrations (backfill-options, - // create-options) + 4 WS-5c migrations (drop-conditional-logic-col, - // backfill-conditional-logic, create-conditional-logic-conditions, + // Roll back to pre-WS-5a state: 3 WS-5d migrations (drop-options-cols, + // backfill-options, create-options) + 4 WS-5c migrations + // (drop-conditional-logic-col, backfill-conditional-logic, + // create-conditional-logic-conditions, // create-conditional-logic-groups) + 5 WS-5b migrations // (drop-validation-cols, configs-backfill, create-configs, // validation-rules-backfill, create-validation-rules) + - // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 13. - $this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful(); + // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 14. + $this->artisan('migrate:rollback', ['--step' => 14])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -101,8 +102,8 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - // Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (13 migrations). - $this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful(); + // Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (14 migrations). + $this->artisan('migrate:rollback', ['--step' => 14])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -112,12 +113,12 @@ final class FormFieldBindingMigrationTest extends TestCase $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertSame(5, DB::table('form_field_bindings')->count()); - // Step back over WS-5d (2 migrations) + WS-5c (4 migrations) + + // Step back over WS-5d (3 migrations) + WS-5c (4 migrations) + // WS-5b (5 migrations) in one go → restores the pre-WS-5b state // (conditional-logic, validation-rules, configs and options tables - // gone, validation_rules JSON columns reappear on source tables; - // binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + // gone, validation_rules + options JSON columns reappear on source + // tables; binding contract intact). + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_options')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 9ac4baa2..1f440ad4 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -31,11 +31,11 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void { - // Roll back the WS-5d backfill-options + create-options + WS-5c - // drop-cl-col + WS-5c backfill-cl migrations to land in the - // conditional-logic JSON-era state with no relational - // form_field_options table yet. - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + // Roll back the WS-5d drop-options-cols + backfill-options + + // create-options + WS-5c drop-cl-col + WS-5c backfill-cl + // migrations to land in the conditional-logic JSON-era state with + // no relational form_field_options table yet. + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -156,7 +156,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -183,7 +183,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -196,7 +196,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->seedFieldWithJson([ 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], diff --git a/api/tests/Feature/FormBuilder/FormFieldOptionsAccessTest.php b/api/tests/Feature/FormBuilder/FormFieldOptionsAccessTest.php new file mode 100644 index 00000000..4d3d210b --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormFieldOptionsAccessTest.php @@ -0,0 +1,110 @@ +options` (no parens) must: + * + * - return an Eloquent Collection + * - whose entries are FormFieldOption instances + * - lazily lazy-load on first access (1 query for the parent fetch + * plus 1 query for the lazy-load — no surprise extra reads) + * + * Tested for both polymorphic owners — FormField and FormFieldLibrary. + */ +final class FormFieldOptionsAccessTest extends TestCase +{ + use RefreshDatabase; + + public function test_form_field_options_resolves_to_morph_many_collection_with_lazy_load(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + FormField::factory() + ->withOptions(['XS', 'S', 'M']) + ->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::SELECT->value, + 'slug' => 'shirtmaat', + ]); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + // Fresh fetch — no with('options') eager-load. The lazy-load + // happens on first $field->options access. + $field = FormField::query()->where('slug', 'shirtmaat')->first(); + $options = $field->options; + + DB::disableQueryLog(); + $queries = DB::getQueryLog(); + + $this->assertInstanceOf(Collection::class, $options); + $this->assertNotEmpty($options); + $this->assertInstanceOf(FormFieldOption::class, $options->first()); + $this->assertSame(['XS', 'S', 'M'], $options->pluck('value')->all()); + + // Exactly two queries: 1× FormField fetch + 1× lazy-load options. + $this->assertCount( + 2, + $queries, + sprintf( + 'Expected exactly 2 queries (parent fetch + lazy-load options); got %d. Queries: %s', + count($queries), + json_encode(array_column($queries, 'query')), + ), + ); + } + + public function test_form_field_library_options_resolves_to_morph_many_collection_with_lazy_load(): void + { + $org = Organisation::factory()->create(); + FormFieldLibrary::factory() + ->withOptions(['a', 'b']) + ->create([ + 'organisation_id' => $org->id, + 'slug' => 'lib-select', + ]); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $library = FormFieldLibrary::query()->where('slug', 'lib-select')->first(); + $options = $library->options; + + DB::disableQueryLog(); + $queries = DB::getQueryLog(); + + $this->assertInstanceOf(Collection::class, $options); + $this->assertNotEmpty($options); + $this->assertInstanceOf(FormFieldOption::class, $options->first()); + $this->assertSame(['a', 'b'], $options->pluck('value')->all()); + + $this->assertCount( + 2, + $queries, + sprintf( + 'Expected exactly 2 queries (parent fetch + lazy-load options); got %d. Queries: %s', + count($queries), + json_encode(array_column($queries, 'query')), + ), + ); + } +} diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php index 40b2e2fd..79e623ca 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -33,7 +33,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Roll back only the backfill migration (latest WS-5d step). // Leaves the form_field_options table in place, JSON columns // present on the source tables, and snapshots in pre-WS-5d shape. - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_options')); $this->assertTrue(Schema::hasColumn('form_fields', 'options')); @@ -113,7 +113,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_rollback_reconstructs_json_columns_and_snapshots(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); $submissionId = $this->seedSubmissionWithSnapshot($selectId); @@ -126,7 +126,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Step back over only the backfill migration → JSON columns repopulate // and snapshots revert to flat-string-array shape. - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->assertSame(0, DB::table('form_field_options')->count()); $select = DB::table('form_fields')->where('id', $selectId)->first(); @@ -145,7 +145,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']); $this->expectException(\RuntimeException::class); @@ -155,7 +155,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_contains_non_string_entry(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ ['label' => 'A'], @@ -169,7 +169,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_is_object_shape(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ 'XS' => 'Extra small', @@ -183,7 +183,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_translations_length_mismatch(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([ 'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 ])); @@ -195,7 +195,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_non_string_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([ 'de' => ['options' => ['Klein', 42]], ])); @@ -207,7 +207,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_oversized_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([ 'de' => ['options' => [str_repeat('x', 256)]], ])); @@ -219,7 +219,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->seedTemplateWithSnapshotRaw([ 'fields' => [[ 'id' => (string) Str::ulid(), diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 62dd2335..3c296c26 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -31,15 +31,15 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_forward_migration_backfills_rows_with_field_type_dispatch(): void { - // Roll back: 2 WS-5d migrations (backfill-options, create-options) + - // 4 WS-5c migrations (drop-conditional-logic-col, + // Roll back: 3 WS-5d migrations (drop-options-cols, backfill-options, + // create-options) + 4 WS-5c migrations (drop-conditional-logic-col, // backfill-conditional-logic, create-conditional-logic-conditions, // create-conditional-logic-groups) + 5 WS-5b migrations // (drop-cols + configs-backfill + create-configs + - // validation-rules-backfill + create-validation-rules) = 11. + // validation-rules-backfill + create-validation-rules) = 12. // Brings us to the pre-WS-5b state: validation_rules JSON column // present, no relational tables for WS-5b/c/d. - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -100,7 +100,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -124,7 +124,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -151,7 +151,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -168,7 +168,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -187,7 +187,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // full-back-then-full-forward cycle — rolling back all WS-5b // migrations restores the pre-WS-5b state (columns present on // source tables; validation rules relational table gone). - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -202,7 +202,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // Roll back WS-5b fully → column reappears and carries canonical JSON // reconstructed from the relational rows. - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); diff --git a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md index 58ff3fe2..daae7166 100644 --- a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md +++ b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md @@ -210,6 +210,26 @@ WS-5c splitst `form_fields.conditional_logic` JSON naar een relationele boom ove **Afronding WS-5c.** 4 commits, baseline tests 1104 → 1148 volledig groen na commit 3 (drop-column). Breaking change acceptance: geen bridging compatibility layer — de portal blijft onaangeraakt omdat het externe JSON-contract identiek is. WS-5d (`options`) is het laatste WS-5-werkpakket. +### Uitvoering — WS-5d (2026-04-27) + +WS-5d splitst zowel `form_fields.options` als `form_field_library.options` naar één polymorfe relationele tabel `form_field_options`. Vierde en laatste WS-5-sibling. Rij-shape: `owner_type` / `owner_id` (morph alias-hergebruik uit WS-5a) + `value` string(255) + `label` string(255) + `sort_order` uint + `translations` JSON nullable per BCP-47 locale. UNIQUE-index `ffo_owner_value_unique` op `(owner_type, owner_id, value)` is de seed-bug-guard: dubbele waarden per veld hebben geen semantiek en moeten zowel op service-niveau (`assertSpecsValid`) als op DB-niveau falen. + +**Snapshot-rewrite atomair in commit 2.** Per zero-compromise directive — geen reader-tolerantie voor pre-WS-5d shape in commit 3 onwards. Iedere bestaande `form_submissions.schema_snapshot.fields[*].options` en `form_templates.schema_snapshot.fields[*].options` is in dezelfde transactie als de `form_field_options` backfill herschreven naar de rich-shape `[{value, label, sort_order, translations?}, ...]`. De parallelle `translations.{locale}.options[]` arrays zijn in één pass van zowel de bron-tabellen als de snapshots gestript — die data leeft nu op de optie-rij zelf in zijn eigen `translations` JSON bag. + +**Strict-fail backfill dispatch.** Mirrort §17.4.4 / §8.7 conventie. De backfill-migratie faalt hard op: (a) `field_type ∉ {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}` met non-null `options` (post-WS-5b TAG_PICKER seed-bug indicator), (b) niet-flat-string-array option shape, (c) translations.{locale}.options[] length mismatch, (d) niet-string of >255-char translated labels, (e) elk overgebleven `translations.{locale}.options` na step C. "Fix at source, don't absorb silently" — net als WS-5b's min/max field-type dispatch. + +**Activity log dual-event op FormField subject only.** Per §6.7 / §17.4.2 / §17.6.3: `field.updated` met gereconstrueerde `old.options` / `new.options` (byte-equal JSON-compare gate om cosmetische false positives te vermijden) plus semantische `field.options_replaced` vanuit `FormFieldOptionService::replaceOptions`. Library-subject writes blijven silent. Diff-key wordt weggelaten wanneer alleen label/sort_order verandert — geen ruis voor downstream activity-log consumers. + +**Vier scope-siblings → base-class extractie nu warranted.** Met `FormFieldOptionScope` als vierde concrete UNION-over-two-owner-chains implementatie naast `FormFieldBindingScope` / `FormFieldValidationRuleScope` / `FormFieldConfigScope`, kan de "wat varieert" vraag eindelijk empirisch beantwoord worden. Base-class extractie is bewust uitgesteld naar een separate follow-up PR — niet meegenomen in WS-5d om de scope strakt te houden. Alle vier siblings zijn near-duplicate; abstractie uit vier kopieën is concrete refactoring, niet meer premature. + +**Commit 0 dead-code precondition.** WS-5d landde op een commit-0 cleanup van `MigrateLegacyFormsData` + `VerifyFormsDataIntegrity` console commands (Pre-form-builder registration_form_fields → form_* tabel migrators). Beide commands waren no-ops sinds de S2a drop van de legacy registration tabellen, en `MigrateLegacyFormsData:225` schreef `$rff->options` direct naar `form_fields.options` — die kolom dropt in commit 5, dus de migrator zou breaking compileren. CLAUDE.md "delete > adapt" — orphaned commands ruimen op vóór ze in de weg zitten. + +**Tijdelijke `getOptionsAttribute` accessor-bridge tijdens commit 3.** Eloquent's `getAttribute()` resolveert in volgorde: `attributes` array, casts, accessor mutators, dán pas relaties. Zolang de `form_fields.options` JSON-kolom op de tabel bestaat (commits 3 → 5) komt elke `$field->options` access uit op de raw column-waarde (string/null), nooit op de morphMany-collectie — ook niet wanneer je `with('options')` eager-load. WS-5d commit 3 plakte een tijdelijke accessor-mutator op beide modellen die `$this->load('options')` aanroept en de relation-collectie teruggeeft, zodat resources / snapshot writer / FormFieldRuleBuilder `$f->options` als drop-in collectie konden gebruiken zonder `()->get()` overal. Commit 5 droppt zowel de kolom als de accessor; vanaf dat punt valt `getAttribute('options')` natuurlijk door naar de relatie. **Pattern voor toekomstige JSON→relationele refactors**: tijdens de overlap-fase een accessor-mutator die de relatie eager-loadt en teruggeeft, gedropt zodra de JSON-kolom verdwijnt. Niet een permanente oplossing — een commit-3-tot-commit-5 bridge. + +**Dev-seeder normalisering van `vergoedingstype` RADIO veld.** De `FormBuilderDevSeeder` schreef pre-WS-5d een nested-object shape `[{label, description}, ...]` op de RADIO `vergoedingstype` veld — drift uit een vroege seed-iteratie die nooit gecorrigeerd is. ARCH §5.1's option-bearing field types (RADIO/SELECT/MULTISELECT/CHECKBOX_LIST) modeleren géén per-option description — de rich-shape is `{value, label, sort_order, translations?}`, descriptions niet voorzien. WS-5d commit 2 normaliseert dit naar flat string array `['Pro Deo', 'Entreeticket', 'Vrijwilligersvergoeding']`; de descriptions zijn drift en zijn dropped. **Niet te verwarren met `apps/app/src/components/{organisation,event}/RegistrationField*.vue`** — die consumeren `RegistrationFieldTemplate` (legacy S1-era `registration_field_templates` tabel) met eigen `normalized_options: [{label, description}]` shape; dat is een parallel domein met eigen API-endpoints en composables, orthogonaal aan WS-5d's `FormField` domein en bewust buiten scope. + +**Afronding WS-5d.** 6 commits (commit 0 cleanup + 5 WS-5d core), baseline tests 1158 → 1208 volledig groen na commit 5. Breaking change acceptance: geen bridging compatibility layer — vier portal componenten (`FieldRadio`, `FieldSelect`, `FieldMultiselect`, `FieldCheckboxList`) gemigreerd naar `OptionSpec[]` rich-shape met locale-aware label-resolutie via `providePublicFormLocale` injectie en `resolveOptionLabel(option, locale)` helper in `@form-schema/types/formBuilder`. apps/app blijft onaangeraakt — `RegistrationField*.vue` componenten consumeren een ander legacy domein dat geen WS-5d migratie nodig heeft. **WS-5 familie compleet.** + --- ## Q4 — Sanctum `personal_access_tokens` diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index a259701b..b5123541 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -1,4 +1,4 @@ -# ARCH — Universal Form Builder (v1.7) +# ARCH — Universal Form Builder (v1.8) > **Source of truth** for Crewli's universal Form Builder architecture. > Any discrepancy with SCHEMA.md is resolved in favour of this document @@ -10,11 +10,18 @@ > columns dropped); WS-5c landed (relational > `form_field_conditional_logic_groups` + `form_field_conditional_logic_conditions`; > pre-WS-5c `conditional_logic` JSON column dropped; no library mirror -> per addendum Q3). -> **Version:** 1.7 (§8 restructured into tree-structure, relational-tables, -> service-boundary, operator-catalogue, cycle-detection, activity-log and -> legacy-migration sub-sections; contract unchanged). +> per addendum Q3); WS-5d landed (relational `form_field_options`; +> pre-WS-5d `options` JSON columns dropped on both `form_fields` and +> `form_field_library`; per-option translations live on the option row +> itself). **WS-5 family complete.** +> **Version:** 1.8 (new §17.6 "Field options (relational)" for the WS-5d +> split; §17.4 / §17.5 sibling-catalogue prose extended to mention the +> fourth concrete morph-scope; existing Webhooks section renumbered +> from §17.6 to §17.7). > **Previous:** +> 1.7 (§8 restructured into tree-structure, relational-tables, +> service-boundary, operator-catalogue, cycle-detection, activity-log +> and legacy-migration sub-sections; contract unchanged), > 1.6 (new §17.5 "Field configuration (non-validation)" for > the `form_field_configs` split; §17.4.4 updated with the > non-validation-key relocation note), @@ -2667,13 +2674,138 @@ The portal + organizer SPAs are updated in the same work package --- -### 17.6 Webhooks +### 17.6 Field options (relational) -#### 17.6.1 Schema +#### 17.6.1 Rationale + +Pre-WS-5d, `form_fields.options` and `form_field_library.options` were +JSON columns of flat string arrays consumed by RADIO / SELECT / +MULTISELECT / CHECKBOX_LIST. The shape conflated three distinct +concerns: the canonical storage value, the default-locale display +label, and per-locale translations (which lived elsewhere as the +parallel `translations.{locale}.options[]` indexed array). WS-5d +splits the bag into one polymorphic relational table where each row is +a single option carrying value, label, sort_order and an optional +per-locale translations JSON map. + +WS-5d follows the WS-5a / WS-5b discipline one-for-one: dedicated +service as single writer, UNION-over-two-owner-chains scope, shared +cascade observer. Fourth and final WS-5 sibling — landing it +materialises the four concrete morph-scope implementations and +unblocks the deliberate follow-up of base-class extraction. + +#### 17.6.2 Table + catalogue + +Single table `form_field_options` carrying: + +- `value` — string ≤255 chars, the canonical storage value used by the + `in:options` validator and embedded in `form_values` rows. UNIQUE per + owner. +- `label` — string ≤255 chars, the default-locale display label. +- `sort_order` — int unsigned, stable ordering within owner. +- `translations` — JSON nullable, `{: }` with + BCP-47 short-form locale keys (`nl`, `en`, `nl_BE`, `en_GB`). + +Polymorphic owner: morph aliases `form_field` and `form_field_library`, +reused from WS-5a. UNIQUE index `ffo_owner_value_unique` on +`(owner_type, owner_id, value)` is the seed-bug guard — duplicate +values per field have no semantic meaning and must fail at both the +service layer (`assertSpecsValid`) and the DB level. Sort-order index +`ffo_owner_sort_idx` on `(owner_type, owner_id, sort_order)` for +ordered fetches. + +Applies only to field types that consume options: +**RADIO / SELECT / MULTISELECT / CHECKBOX_LIST**. TAG_PICKER's category +filter lives in `form_field_configs` (§17.5); +AVAILABILITY_PICKER and SECTION_PRIORITY source options dynamically +from sibling endpoints. Any other field type carrying non-null options +in pre-WS-5d data is a seed bug and the strict-fail backfill rejects +it. + +#### 17.6.3 Service / scope / cascade / activity log + +`FormFieldOptionService` is the single writer. Public surface: + +- `optionsFor(owner)` — eager, ordered by sort_order +- `replaceOptions(owner, specs)` — transactional: validate spec list, + delete prior rows, insert new rows. Returns the fresh collection. +- `copyOptions(from, to)` — pure row-clone for + `FormFieldService::insertFromLibrary` per the addendum Q3 row-copy + mandate. No activity-log emit (the wrapping field-creation event + carries the audit). +- `toJsonShape(collection)` — serialises to the rich-shape array used + by snapshot writer, API resources and `FilterRegistryController`. +- `assertSpecsValid(specs)` — public spec-shape gate, used by + FormRequests in their `after()` hook to reject malformed specs at the + HTTP boundary before any write. + +`FormFieldOptionScope` is the fourth concrete UNION-over-two-owner- +chains sibling, near-duplicate of the binding / validation-rules / +configs scopes. Base-class extraction across the four siblings is +deliberately deferred to a follow-up work package now that the four +implementations exist and the "what actually varies" question can be +answered empirically. + +`FormFieldChildTablesCascadeObserver` extended to physically delete +option rows on owner soft-delete OR force-delete; options are +physical state, not audit (submission snapshots carry the historical +shape). + +Activity log dual-emit on FormField subject only (mirrors §6.7 / +§17.4.2): + +- `field.updated` carries `old.options` / `new.options` diff via + `toJsonShape()` reconstruction. The diff is byte-equal JSON-compared + to skip cosmetic false positives — bare label/sort_order updates + that don't touch options omit the key entirely. +- `field.options_replaced` is the semantic event from + `replaceOptions()`, payload `{options: [...rich shape...]}`. + +Library-subject writes are silent in activity log (consistent with +WS-5a / WS-5b convention; library audits live elsewhere). + +#### 17.6.4 Snapshot embedding + +`FormSubmissionService::buildSnapshot` walks `fields[*]` and emits +options through `FormFieldOptionService::toJsonShape()` in the same +rich shape exposed by API resources. The pre-WS-5d +`translations.{locale}.options[]` parallel arrays are dead — option +translations live on each option row's own `translations` JSON. The +field-snapshot's translations bag retains only `{label, help_text}` +per locale. WS-5d commit 2's backfill rewrote every existing +submission + template snapshot in-place; no historical flat-array +options remain post-commit-2. + +#### 17.6.5 External API contract (no bridging) + +Resources, snapshot writer, and `FilterRegistryController` emit +options uniformly as the rich shape: + +```json +[ + {"value": "red", "label": "Red", "sort_order": 0, + "translations": {"nl": "Rood"}}, + {"value": "green", "label": "Green", "sort_order": 1} +] +``` + +Empty option set serialises as `null` (preserves the option-less +field-type contract). Per ARCH-FORM-BUILDER §0 "Breaking change +acceptance", the portal SPA was migrated atomically in WS-5d commit 4 +with no flat-array carve-out. Downstream consumers wanting the raw +value list extract `options.map(o => o.value)`; consumers wanting +Vuetify-style `{value, title}` pairs use `resolveOptionLabel(option, +locale)` from `@form-schema/types/formBuilder` and map over. + +--- + +### 17.7 Webhooks + +#### 17.7.1 Schema See §4.11 `form_schema_webhooks` and §4.12 `form_webhook_deliveries`. -#### 17.6.2 Dispatcher +#### 17.7.2 Dispatcher `FormWebhookDispatcher` listens for FormSubmissionSubmitted / Reviewed / SectionSubmitted / SectionReviewed events. On trigger: @@ -2681,7 +2813,7 @@ SectionSubmitted / SectionReviewed events. On trigger: - For each: creates a form_webhook_delivery row with status=pending - Queues `DeliverFormWebhookJob` per delivery on dedicated `webhooks` queue -#### 17.6.3 Delivery job +#### 17.7.3 Delivery job `DeliverFormWebhookJob` on `webhooks` queue: - Idempotent (Laravel job with unique ID per delivery) @@ -2700,7 +2832,7 @@ SectionSubmitted / SectionReviewed events. On trigger: Response body first 1000 chars stored in `response_body_excerpt` for debugging. -#### 17.6.4 Security +#### 17.7.4 Security URL validation in `FormWebhookDispatcher`: - Parse URL; reject non-http(s) @@ -2712,7 +2844,7 @@ URL validation in `FormWebhookDispatcher`: Admin UI shows validation status + last delivery attempt per webhook. -#### 17.6.5 Webhook payload format +#### 17.7.5 Webhook payload format ```json { diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 64ff7dd0..4982ebef 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -336,12 +336,21 @@ shifts claimen zonder toegang tot de Organizer app. --- -### FORM-BUILDER-LIBRARY-AUDIT-LOG — Audit FormFieldLibrary-level changes to bindings, validation rules, and configs +### FORM-BUILDER-LIBRARY-AUDIT-LOG — Audit FormFieldLibrary-level changes to bindings, validation rules, configs, and options -**Aanleiding:** Post-WS-5b, three form-builder child-table services (`FormFieldBindingService`, `FormFieldValidationRuleService`, `FormFieldConfigService`) emit activity-log events on FormField subjects only. Changes to FormFieldLibrary entries — which affect organisation-wide reusable field definitions — land silently in the audit log. This is the consistent behaviour inherited from WS-5a and extended through WS-5b, but it represents an audit-trail gap for library administration. -**Wat:** introduce parallel `library.*` activity-log events (`library.bindings_replaced`, `library.validation_rules_replaced`, `library.configs_replaced`) emitted by the same three services when the owner is a `FormFieldLibrary`. Document the convention addition in `ARCH-FORM-BUILDER.md` §6.7 and §17.4.2 + §17.5.2. Single cross-cutting work package. +**Aanleiding:** Post-WS-5d, four form-builder child-table services (`FormFieldBindingService`, `FormFieldValidationRuleService`, `FormFieldConfigService`, `FormFieldOptionService`) emit activity-log events on FormField subjects only. Changes to FormFieldLibrary entries — which affect organisation-wide reusable field definitions — land silently in the audit log. This is the consistent behaviour inherited from WS-5a and extended through WS-5b/c/d, but it represents an audit-trail gap for library administration. +**Wat:** introduce parallel `library.*` activity-log events (`library.bindings_replaced`, `library.validation_rules_replaced`, `library.configs_replaced`, `library.options_replaced`) emitted by the same four services when the owner is a `FormFieldLibrary`. Document the convention addition in `ARCH-FORM-BUILDER.md` §6.7 and §17.4.2 + §17.5.2 + §17.6.3. Single cross-cutting work package. **Prioriteit:** Middel — geen blocker; candidate sprint post-WS-5, before any external audit tooling is wired up (consumers shouldn't have to deal with the asymmetry). -**Related:** WS-5a §6.7 activity log events paragraph; WS-5b §17.4.2 / §17.5.2 paragraphs. +**Related:** WS-5a §6.7 activity log events paragraph; WS-5b §17.4.2 / §17.5.2 paragraphs; WS-5d §17.6.3 paragraph. + +--- + +### FORM-BUILDER-MORPH-SCOPE-BASE-CLASS — Extract base class across the four WS-5 morph-scope siblings + +**Aanleiding:** Post-WS-5d, four near-duplicate scope classes implement the same UNION-over-two-owner-chains shape: `FormFieldBindingScope` (WS-5a), `FormFieldValidationRuleScope` (WS-5b), `FormFieldConfigScope` (WS-5b), `FormFieldOptionScope` (WS-5d). Each is ~40 lines of boilerplate plus the morph-alias names; the `apply()` body and the `resolveOrganisationId()` helper are byte-identical across all four. Base-class extraction was deferred during each WS-5 commit on the principle of "abstraction from N copies is premature when N+1 lands soon"; with the fourth concrete implementation now in place, the "what actually varies" question can be answered empirically. +**Wat:** Extract `App\Models\Scopes\FormFieldChildTableScope` abstract base. Subclasses declare only the two morph alias strings (`form_field`, `form_field_library`) — actually identical across all four siblings, so even the subclasses become trivial. Keep the four concrete classes as named entry points (the `withoutGlobalScope(FormFieldOptionScope::class)` escape-hatch contract is API surface). Single low-risk refactor PR; full WS-5 test suite must stay green byte-for-byte. +**Prioriteit:** Middel — quality-of-life refactor. No functional change, no schema change, no contract change. Land before any further child-table morph-pattern is introduced. +**Related:** Addendum §Q3 WS-5d Uitvoering paragraph "Vier scope-siblings → base-class extractie nu warranted". --- diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 1929ed7b..e7a97aea 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,10 +1,28 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.5** — Updated April 2026 +> **Version: 2.6** — Updated April 2026 > > **Changelog:** > +> - v2.6: WS-5d — `form_fields.options` and `form_field_library.options` +> JSON columns **dropped**; replaced by a single polymorphic relational +> table `form_field_options` (rows owned via `owner_type` / +> `owner_id`, reusing the `form_field` / `form_field_library` morph +> aliases from WS-5a). Row carries `value` + `label` + `sort_order` + +> `translations` JSON (per-locale BCP-47 string map). UNIQUE index on +> `(owner_type, owner_id, value)` enforces no duplicate values per +> owner. Submission + template snapshots rewritten in-place to the +> rich-shape `[{value, label, sort_order, translations?}, ...]`; the +> parallel pre-WS-5d `translations.{locale}.options[]` arrays stripped +> from both source rows and field-snapshot translation bags — option +> translations live on the option row now. WS-5 family complete; base +> morph-scope class extraction across the four siblings +> (`FormFieldBindingScope`, `FormFieldValidationRuleScope`, +> `FormFieldConfigScope`, `FormFieldOptionScope`) deferred to a +> follow-up work package now that all four concrete implementations +> exist. See ARCH-FORM-BUILDER.md §17.6 and +> ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 §Q3 WS-5d Uitvoering. > - v2.5: WS-5c — `form_fields.conditional_logic` JSON column **dropped**; > replaced by a two-table relational tree: > `form_field_conditional_logic_groups` (AND/OR nodes with optional @@ -2009,7 +2027,6 @@ that aggregates the user's submitted, non-test `form_submissions`. | `field_type` | string(50) | One of `FormFieldType` or a registered custom type | | `label` | string | | | `help_text` | text nullable | | -| `options` | JSON nullable | Choice options | | `default_is_required` | bool | default: false | | `default_is_filterable` | bool | default: false | | `translations` | JSON nullable | Per-locale overrides | @@ -2019,7 +2036,7 @@ that aggregates the user's submitted, non-test `form_submissions`. | `is_active` | bool | default: true | | `created_at`, `updated_at` | timestamps | | -**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id`; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner` +**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id`; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`; `morphMany` form_field_options as `owner` **Indexes:** `(organisation_id, field_type)`, `(organisation_id, is_active)` **Unique constraint:** `UNIQUE(organisation_id, slug)` **Global scope:** `OrganisationScope` @@ -2033,6 +2050,11 @@ that aggregates the user's submitted, non-test `form_submissions`. > table (ARCH-FORM-BUILDER.md §17.4). The column was dropped in WS-5b; > see `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5b > Uitvoering for the full catalogue and migration notes. +> +> Options moved to the relational `form_field_options` table +> (ARCH-FORM-BUILDER.md §17.6). The column was dropped in WS-5d; +> see `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5d +> Uitvoering. --- @@ -2058,7 +2080,6 @@ that aggregates the user's submitted, non-test `form_submissions`. | `label` | string | Default-locale label | | `help_text` | text nullable | | | `section` | string(100) null | Visual grouping header (independent of `form_schema_section_id`) | -| `options` | JSON nullable | Choice options | | `is_required` | bool | default: false | | `is_filterable` | bool | default: false — populates `form_values.value_indexed` / pivot | | `is_portal_visible` | bool | default: true | @@ -2067,14 +2088,14 @@ that aggregates the user's submitted, non-test `form_submissions`. | `is_pii` | bool | default: false — drives retention + anonymisation | | `display_width` | string(10) | default: `full`; `FormFieldDisplayWidth` enum | | `role_restrictions` | JSON nullable | Per-field RBAC driving `FieldAccessService` | -| `translations` | JSON nullable | `{ : { label, help_text, options } }` | +| `translations` | JSON nullable | `{ : { label, help_text } }` (per-option translations live on `form_field_options.translations` post-WS-5d) | | `value_storage_hint` | string(10) | default: `json`. `FormValueStorageHint` enum — guides typed-column population | | `review_required` | bool | default: false | | `sort_order` | int unsigned | default: 0 | | `created_at`, `updated_at` | timestamps | | | `deleted_at` | timestamp nullable | Soft delete preserves history | -**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values, conditionalLogicGroups; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner` +**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values, conditionalLogicGroups; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`; `morphMany` form_field_options as `owner` **Indexes:** `(form_schema_id, sort_order)`, `(form_schema_id, is_filterable)`, `(library_field_id)`, `(form_schema_id, slug)` **Soft delete:** yes @@ -2093,6 +2114,11 @@ that aggregates the user's submitted, non-test `form_submissions`. > `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5c > Uitvoering. No library mirror — addendum Q3 excludes library from > conditional_logic scope. +> +> Options moved to the relational `form_field_options` table +> (ARCH-FORM-BUILDER.md §17.6). The column was dropped in WS-5d; see +> `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5d +> Uitvoering. --- @@ -2186,6 +2212,36 @@ that aggregates the user's submitted, non-test `form_submissions`. --- +### `form_field_options` + +> Relational home for option rows on RADIO / SELECT / MULTISELECT / +> CHECKBOX_LIST fields, replacing the pre-WS-5d +> `form_fields.options` and `form_field_library.options` JSON columns. +> Same polymorphic-morph pattern as the binding / validation-rules / +> configs siblings. Each row carries the option's storage value, the +> default-locale display label, a stable sort_order within owner, and +> an optional per-locale translations bag. ARCH-FORM-BUILDER.md §17.6; +> addendum §Q3 WS-5d Uitvoering. + +| Column | Type | Notes | +| -------------- | --------------- | ---------------------------------------------------------------- | +| `id` | ULID | PK | +| `owner_type` | string(40) | morph alias: `form_field` or `form_field_library` | +| `owner_id` | ULID | parent row (a `form_fields.id` or `form_field_library.id`) | +| `value` | string(255) | canonical storage value (used by `in:options` validator) | +| `label` | string(255) | default-locale display label | +| `sort_order` | int unsigned | default: 0; stable ordering within owner | +| `translations` | JSON nullable | `{ : }` (BCP-47 short form keys) | +| `created_at`, `updated_at` | timestamps | | + +**Relations:** `morphTo` owner (`form_field` or `form_field_library`) +**Indexes:** `(owner_type, owner_id, sort_order)` as `ffo_owner_sort_idx` +**Unique constraint:** `UNIQUE(owner_type, owner_id, value)` as `ffo_owner_value_unique` — seed-bug guard +**Global scope:** `FormFieldOptionScope` — fourth and final sibling in the morph-scope family (alongside `FormFieldBindingScope`, `FormFieldValidationRuleScope`, `FormFieldConfigScope`); same UNION shape. Escape hatch: `withoutGlobalScope(FormFieldOptionScope::class)`. Base-class extraction across the four siblings deferred to a follow-up work package now that all four concrete implementations exist. +**Soft delete:** no — options are current state, not audit. Submission snapshots carry the historical shape. + +--- + ### `form_field_conditional_logic_groups` > Tree nodes (root + branches) of the relational conditional-logic