From d494478c081d4d4dca3dbfde00e1a22f2553ebff Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 22:42:35 +0200 Subject: [PATCH] feat(form-builder): form_field_configs relational table + non-validation key split + drop validation_rules JSON columns --- .../Enums/FormBuilder/FormFieldConfigType.php | 22 +++ .../FormBuilder/FormFieldLibraryResource.php | 4 + .../FormBuilder/FormFieldResource.php | 7 +- .../FormBuilder/PublicFormSchemaResource.php | 6 +- api/app/Models/FormBuilder/FormField.php | 7 +- .../Models/FormBuilder/FormFieldConfig.php | 52 +++++ .../Models/FormBuilder/FormFieldLibrary.php | 7 +- .../Models/Scopes/FormFieldConfigScope.php | 102 ++++++++++ .../FormFieldChildTablesCascadeObserver.php | 7 + .../FormBuilder/FormFieldConfigService.php | 187 ++++++++++++++++++ .../Services/FormBuilder/FormFieldService.php | 3 +- .../FormBuilder/FormSubmissionService.php | 4 +- .../FormBuilder/FormFieldConfigFactory.php | 53 +++++ .../FormBuilder/FormFieldFactory.php | 1 - .../FormBuilder/FormFieldLibraryFactory.php | 1 - ...120000_create_form_field_configs_table.php | 41 ++++ ..._25_120001_backfill_form_field_configs.php | 148 ++++++++++++++ ...002_drop_validation_rules_json_columns.php | 49 +++++ api/database/seeders/FormBuilderDevSeeder.php | 8 +- .../PublicFormSchemaResourceTest.php | 8 +- .../FormFieldBindingMigrationTest.php | 26 +-- .../FormFieldConfigBackfillAndDropTest.php | 112 +++++++++++ .../FormFieldConfigServiceAndScopeTest.php | 184 +++++++++++++++++ .../FormFieldValidationRuleBackfillTest.php | 58 ++++-- .../components/public-form/FieldNumber.vue | 4 +- .../public-form/FieldSectionPriority.vue | 4 +- .../src/components/public-form/FieldText.vue | 2 +- .../components/public-form/FieldTextarea.vue | 2 +- .../ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md | 11 ++ dev-docs/ARCH-FORM-BUILDER.md | 125 +++++++++++- dev-docs/SCHEMA.md | 48 ++++- 31 files changed, 1233 insertions(+), 60 deletions(-) create mode 100644 api/app/Enums/FormBuilder/FormFieldConfigType.php create mode 100644 api/app/Models/FormBuilder/FormFieldConfig.php create mode 100644 api/app/Models/Scopes/FormFieldConfigScope.php create mode 100644 api/app/Services/FormBuilder/FormFieldConfigService.php create mode 100644 api/database/factories/FormBuilder/FormFieldConfigFactory.php create mode 100644 api/database/migrations/2026_04_25_120000_create_form_field_configs_table.php create mode 100644 api/database/migrations/2026_04_25_120001_backfill_form_field_configs.php create mode 100644 api/database/migrations/2026_04_25_120002_drop_validation_rules_json_columns.php create mode 100644 api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php create mode 100644 api/tests/Feature/FormBuilder/Configs/FormFieldConfigServiceAndScopeTest.php diff --git a/api/app/Enums/FormBuilder/FormFieldConfigType.php b/api/app/Enums/FormBuilder/FormFieldConfigType.php new file mode 100644 index 00000000..eec87442 --- /dev/null +++ b/api/app/Enums/FormBuilder/FormFieldConfigType.php @@ -0,0 +1,22 @@ + app(FormFieldValidationRuleService::class)->toJsonShape( $this->resource->validationRules, ), + 'configs' => app(FormFieldConfigService::class)->toJsonShape( + $this->resource->configs, + ), 'default_is_required' => (bool) $this->default_is_required, 'default_is_filterable' => (bool) $this->default_is_filterable, 'default_binding' => app(FormFieldBindingService::class)->toJsonShape( diff --git a/api/app/Http/Resources/FormBuilder/FormFieldResource.php b/api/app/Http/Resources/FormBuilder/FormFieldResource.php index 5d7db3a7..42fcba98 100644 --- a/api/app/Http/Resources/FormBuilder/FormFieldResource.php +++ b/api/app/Http/Resources/FormBuilder/FormFieldResource.php @@ -8,6 +8,7 @@ use App\Enums\FormBuilder\FormFieldType; use App\Models\FormBuilder\FormField; use App\Models\PersonTag; use App\Services\FormBuilder\FormFieldBindingService; +use App\Services\FormBuilder\FormFieldConfigService; use App\Services\FormBuilder\FormFieldValidationRuleService; use App\Services\FormBuilder\FormLocaleResolver; use Illuminate\Http\Request; @@ -46,6 +47,9 @@ final class FormFieldResource extends JsonResource 'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape( $this->resource->validationRules, ), + 'configs' => app(FormFieldConfigService::class)->toJsonShape( + $this->resource->configs, + ), 'is_required' => (bool) $this->is_required, 'is_filterable' => (bool) $this->is_filterable, 'is_portal_visible' => (bool) $this->is_portal_visible, @@ -112,7 +116,8 @@ final class FormFieldResource extends JsonResource return []; } - $categoryFilter = (array) (($this->validation_rules['tag_categories'] ?? null) ?: []); + $configs = app(FormFieldConfigService::class)->toJsonShape($this->resource->configs); + $categoryFilter = (array) ($configs['tag_categories']['categories'] ?? []); $query = PersonTag::withoutGlobalScopes() ->where('organisation_id', $organisationId) diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php index fcadb09f..0b2e9626 100644 --- a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php +++ b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php @@ -9,6 +9,7 @@ use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; use App\Models\PersonTag; use App\Models\Scopes\OrganisationScope; +use App\Services\FormBuilder\FormFieldConfigService; use App\Services\FormBuilder\FormFieldValidationRuleService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -39,6 +40,7 @@ final class PublicFormSchemaResource extends JsonResource $this->resource->loadMissing([ 'fields' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class), 'fields.validationRules', + 'fields.configs', 'sections' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class), ]); @@ -84,6 +86,7 @@ final class PublicFormSchemaResource extends JsonResource 'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape( $f->validationRules, ), + 'configs' => app(FormFieldConfigService::class)->toJsonShape($f->configs), 'is_required' => (bool) $f->is_required, 'display_width' => $f->display_width instanceof \BackedEnum ? $f->display_width->value : $f->display_width, 'conditional_logic' => $f->conditional_logic, @@ -141,7 +144,8 @@ final class PublicFormSchemaResource extends JsonResource */ private function tagsForField(FormField $field, array $byCategory): array { - $filter = (array) (($field->validation_rules['tag_categories'] ?? null) ?: []); + $configs = app(FormFieldConfigService::class)->toJsonShape($field->configs); + $filter = (array) ($configs['tag_categories']['categories'] ?? []); if ($filter === []) { $out = []; diff --git a/api/app/Models/FormBuilder/FormField.php b/api/app/Models/FormBuilder/FormField.php index 0b40ac6c..c0f0b6a8 100644 --- a/api/app/Models/FormBuilder/FormField.php +++ b/api/app/Models/FormBuilder/FormField.php @@ -51,7 +51,6 @@ final class FormField extends Model 'help_text', 'section', 'options', - 'validation_rules', 'is_required', 'is_filterable', 'is_portal_visible', @@ -70,7 +69,6 @@ final class FormField extends Model /** @var array */ protected $casts = [ 'options' => 'array', - 'validation_rules' => 'array', 'conditional_logic' => 'array', 'role_restrictions' => 'array', 'translations' => 'array', @@ -116,6 +114,11 @@ final class FormField extends Model return $this->morphMany(FormFieldValidationRule::class, 'owner'); } + public function configs(): MorphMany + { + return $this->morphMany(FormFieldConfig::class, 'owner'); + } + /** * Nuanced activity log (ARCH §17.1; S1 Phase 4b). Callers choose which * events are worth logging — e.g. created/deleted/restored, field_type diff --git a/api/app/Models/FormBuilder/FormFieldConfig.php b/api/app/Models/FormBuilder/FormFieldConfig.php new file mode 100644 index 00000000..27c2926f --- /dev/null +++ b/api/app/Models/FormBuilder/FormFieldConfig.php @@ -0,0 +1,52 @@ + */ + protected $casts = [ + 'config_type' => FormFieldConfigType::class, + 'parameters' => 'array', + ]; + + public function owner(): MorphTo + { + return $this->morphTo('owner', 'owner_type', 'owner_id'); + } +} diff --git a/api/app/Models/FormBuilder/FormFieldLibrary.php b/api/app/Models/FormBuilder/FormFieldLibrary.php index 57fc46a8..792c0895 100644 --- a/api/app/Models/FormBuilder/FormFieldLibrary.php +++ b/api/app/Models/FormBuilder/FormFieldLibrary.php @@ -35,7 +35,6 @@ final class FormFieldLibrary extends Model 'label', 'help_text', 'options', - 'validation_rules', 'default_is_required', 'default_is_filterable', 'translations', @@ -46,7 +45,6 @@ final class FormFieldLibrary extends Model /** @var array */ protected $casts = [ 'options' => 'array', - 'validation_rules' => 'array', 'translations' => 'array', 'default_is_required' => 'bool', 'default_is_filterable' => 'bool', @@ -74,4 +72,9 @@ final class FormFieldLibrary extends Model { return $this->morphMany(FormFieldValidationRule::class, 'owner'); } + + public function configs(): MorphMany + { + return $this->morphMany(FormFieldConfig::class, 'owner'); + } } diff --git a/api/app/Models/Scopes/FormFieldConfigScope.php b/api/app/Models/Scopes/FormFieldConfigScope.php new file mode 100644 index 00000000..ab07a783 --- /dev/null +++ b/api/app/Models/Scopes/FormFieldConfigScope.php @@ -0,0 +1,102 @@ +resolveOrganisationId(); + if ($orgId === null) { + return; + } + + $fieldIds = FormField::query() + ->withoutGlobalScope(OrganisationScope::class) + ->whereIn( + 'form_schema_id', + FormSchema::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'), + ) + ->select('id'); + + $libraryIds = FormFieldLibrary::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'); + + $table = $model->getTable(); + + $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { + $outer->where(function (Builder $q) use ($table, $fieldIds): void { + $q->where("$table.owner_type", 'form_field') + ->whereIn("$table.owner_id", $fieldIds); + })->orWhere(function (Builder $q) use ($table, $libraryIds): void { + $q->where("$table.owner_type", 'form_field_library') + ->whereIn("$table.owner_id", $libraryIds); + }); + }); + } + + private function resolveOrganisationId(): ?string + { + if ($this->organisationId !== null) { + return $this->organisationId; + } + + $route = request()->route(); + if ($route === null) { + return null; + } + + $org = $route->parameter('organisation'); + + if ($org instanceof \App\Models\Organisation) { + return $org->id; + } + + if (is_string($org) && $org !== '') { + return $org; + } + + $event = $route->parameter('event'); + if ($event instanceof \App\Models\Event) { + return $event->organisation_id; + } + + return null; + } +} diff --git a/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php index 67fcf9d7..c6468df6 100644 --- a/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php +++ b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php @@ -45,5 +45,12 @@ final class FormFieldChildTablesCascadeObserver ->where('owner_id', $ownerId) ->delete(); } + + if (Schema::hasTable('form_field_configs')) { + DB::table('form_field_configs') + ->where('owner_type', $ownerType) + ->where('owner_id', $ownerId) + ->delete(); + } } } diff --git a/api/app/Services/FormBuilder/FormFieldConfigService.php b/api/app/Services/FormBuilder/FormFieldConfigService.php new file mode 100644 index 00000000..911c553d --- /dev/null +++ b/api/app/Services/FormBuilder/FormFieldConfigService.php @@ -0,0 +1,187 @@ + + */ + public function configsFor(FormField|FormFieldLibrary $owner): Collection + { + $type = $this->ownerTypeFor($owner); + + return FormFieldConfig::query() + ->where('owner_type', $type) + ->where('owner_id', $owner->getKey()) + ->get(); + } + + /** + * @param list}> $specs + */ + public function replaceConfigs(FormField|FormFieldLibrary $owner, array $specs): void + { + $this->assertSpecsValid($specs); + + $ownerType = $this->ownerTypeFor($owner); + + DB::transaction(function () use ($owner, $ownerType, $specs): void { + FormFieldConfig::query() + ->withoutGlobalScopes() + ->where('owner_type', $ownerType) + ->where('owner_id', $owner->getKey()) + ->delete(); + + foreach ($specs as $spec) { + FormFieldConfig::query()->withoutGlobalScopes()->create([ + 'owner_type' => $ownerType, + 'owner_id' => $owner->getKey(), + 'config_type' => $spec['config_type'], + 'parameters' => $spec['parameters'] ?? [], + ]); + } + + if ($owner instanceof FormField) { + $owner->logFieldChange('field.configs_replaced', [ + 'count' => count($specs), + ]); + } + }); + } + + public function copyConfigs(FormFieldLibrary $from, FormField $to): void + { + $configs = $this->configsFor($from); + + if ($configs->isEmpty()) { + return; + } + + DB::transaction(function () use ($configs, $to): void { + foreach ($configs as $config) { + FormFieldConfig::query()->withoutGlobalScopes()->create([ + 'owner_type' => 'form_field', + 'owner_id' => $to->id, + 'config_type' => $config->config_type instanceof FormFieldConfigType + ? $config->config_type->value + : (string) $config->config_type, + 'parameters' => (array) $config->parameters, + ]); + } + }); + } + + /** + * Serialise a config collection into the nested-object JSON shape + * consumed by snapshot writer and API resources. Returns `null` on + * empty (matches the contract pattern WS-5b introduced on the + * validation-rules service). + * + * Shape per config_type: + * - tag_categories → `{"categories": [string]}` + * - storage_disk → `{"disk": string}` + * + * The external envelope is `{: }`: + * `{"tag_categories": {"categories": ["Veiligheid"]}, + * "storage_disk": {"disk": "local"}}` + * + * @param Collection $configs + * @return array>|null + */ + public function toJsonShape(Collection $configs): ?array + { + if ($configs->isEmpty()) { + return null; + } + + $out = []; + foreach ($configs as $config) { + $type = $config->config_type instanceof FormFieldConfigType + ? $config->config_type->value + : (string) $config->config_type; + $out[$type] = (array) $config->parameters; + } + + return $out; + } + + /** @param list> $specs */ + public function assertSpecsValid(array $specs): void + { + foreach ($specs as $spec) { + $this->assertSpecValid($spec); + } + } + + private function ownerTypeFor(FormField|FormFieldLibrary $owner): string + { + return $owner instanceof FormField ? 'form_field' : 'form_field_library'; + } + + /** @param array $spec */ + private function assertSpecValid(array $spec): void + { + $raw = (string) ($spec['config_type'] ?? ''); + $enum = FormFieldConfigType::tryFrom($raw); + if ($enum === null) { + throw new UnknownValidationRuleTypeException( + "Config config_type '{$raw}' is not a registered FormFieldConfigType case.", + ); + } + + $params = (array) ($spec['parameters'] ?? []); + + switch ($enum) { + case FormFieldConfigType::TagCategories: + if (! isset($params['categories']) || ! is_array($params['categories'])) { + throw new UnknownValidationRuleTypeException( + "Config 'tag_categories' requires parameters.categories (array of strings).", + ); + } + foreach ($params['categories'] as $cat) { + if (! is_string($cat) || $cat === '') { + throw new UnknownValidationRuleTypeException( + "Config 'tag_categories' parameters.categories must be non-empty strings.", + ); + } + } + + return; + + case FormFieldConfigType::StorageDisk: + if (! isset($params['disk']) || ! is_string($params['disk']) || $params['disk'] === '') { + throw new UnknownValidationRuleTypeException( + "Config 'storage_disk' requires non-empty string parameters.disk.", + ); + } + + return; + } + } +} diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php index f80f0674..80ab2692 100644 --- a/api/app/Services/FormBuilder/FormFieldService.php +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -28,6 +28,7 @@ final class FormFieldService private readonly FormSchemaService $schemaService, private readonly FormFieldBindingService $bindingService, private readonly FormFieldValidationRuleService $validationRuleService, + private readonly FormFieldConfigService $configService, ) {} public function create(FormSchema $schema, array $data): FormField @@ -242,7 +243,6 @@ final class FormFieldService 'label' => $library->label, 'help_text' => $library->help_text, 'options' => $library->options, - 'validation_rules' => $library->validation_rules, 'is_required' => (bool) $library->default_is_required, 'is_filterable' => (bool) $library->default_is_filterable, 'translations' => $library->translations, @@ -260,6 +260,7 @@ final class FormFieldService $this->bindingService->copyBindings($library, $field); $this->validationRuleService->copyRules($library, $field); + $this->configService->copyConfigs($library, $field); FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count'); diff --git a/api/app/Services/FormBuilder/FormSubmissionService.php b/api/app/Services/FormBuilder/FormSubmissionService.php index 90dd1e4d..47635a97 100644 --- a/api/app/Services/FormBuilder/FormSubmissionService.php +++ b/api/app/Services/FormBuilder/FormSubmissionService.php @@ -35,6 +35,7 @@ final class FormSubmissionService private readonly FormValueService $valueService, private readonly FormFieldBindingService $bindingService, private readonly FormFieldValidationRuleService $validationRuleService, + private readonly FormFieldConfigService $configService, ) {} /** @@ -201,7 +202,7 @@ final class FormSubmissionService */ private function buildSnapshot(FormSchema $schema): array { - $schema->loadMissing(['fields.bindings', 'fields.validationRules', 'sections']); + $schema->loadMissing(['fields.bindings', 'fields.validationRules', 'fields.configs', 'sections']); return [ 'schema_version' => $schema->version, @@ -234,6 +235,7 @@ final class FormSubmissionService 'section_slug' => $this->sectionSlug($schema, $f->form_schema_section_id), 'options' => $f->options, 'validation_rules' => $this->validationRuleService->toJsonShape($f->validationRules), + 'configs' => $this->configService->toJsonShape($f->configs), 'is_required' => (bool) $f->is_required, 'is_filterable' => (bool) $f->is_filterable, 'is_pii' => (bool) $f->is_pii, diff --git a/api/database/factories/FormBuilder/FormFieldConfigFactory.php b/api/database/factories/FormBuilder/FormFieldConfigFactory.php new file mode 100644 index 00000000..e63eb3a2 --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldConfigFactory.php @@ -0,0 +1,53 @@ + */ +final class FormFieldConfigFactory extends Factory +{ + protected $model = FormFieldConfig::class; + + /** @return array */ + public function definition(): array + { + return [ + 'owner_type' => 'form_field', + 'owner_id' => FormField::factory(), + 'config_type' => FormFieldConfigType::TagCategories->value, + 'parameters' => ['categories' => ['Veiligheid']], + ]; + } + + public function forField(FormField $field): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field', + 'owner_id' => $field->id, + ]); + } + + public function forLibrary(FormFieldLibrary $library): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field_library', + 'owner_id' => $library->id, + ]); + } + + /** @param array $parameters */ + public function ofType(FormFieldConfigType $type, array $parameters): static + { + return $this->state(fn () => [ + 'config_type' => $type->value, + 'parameters' => $parameters, + ]); + } +} diff --git a/api/database/factories/FormBuilder/FormFieldFactory.php b/api/database/factories/FormBuilder/FormFieldFactory.php index caa01f7e..59d426d7 100644 --- a/api/database/factories/FormBuilder/FormFieldFactory.php +++ b/api/database/factories/FormBuilder/FormFieldFactory.php @@ -47,7 +47,6 @@ final class FormFieldFactory extends Factory 'options' => $fieldType === FormFieldType::SELECT ? ['Optie A', 'Optie B', 'Optie C'] : null, - 'validation_rules' => null, 'is_required' => fake()->boolean(40), 'is_filterable' => false, 'is_portal_visible' => true, diff --git a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php index edac1693..ef1dbd94 100644 --- a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php +++ b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php @@ -35,7 +35,6 @@ final class FormFieldLibraryFactory extends Factory 'label' => fake('nl_NL')->words(2, true), 'help_text' => null, 'options' => null, - 'validation_rules' => null, 'default_is_required' => false, 'default_is_filterable' => false, 'translations' => null, diff --git a/api/database/migrations/2026_04_25_120000_create_form_field_configs_table.php b/api/database/migrations/2026_04_25_120000_create_form_field_configs_table.php new file mode 100644 index 00000000..24bb8f92 --- /dev/null +++ b/api/database/migrations/2026_04_25_120000_create_form_field_configs_table.php @@ -0,0 +1,41 @@ +ulid('id')->primary(); + $table->string('owner_type', 40); + $table->ulid('owner_id'); + $table->string('config_type', 40); + $table->json('parameters'); + $table->timestamps(); + + $table->unique( + ['owner_type', 'owner_id', 'config_type'], + 'ffc_owner_config_unique', + ); + $table->index('config_type', 'ffc_config_idx'); + $table->index(['owner_type', 'owner_id'], 'ffc_owner_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('form_field_configs'); + } +}; diff --git a/api/database/migrations/2026_04_25_120001_backfill_form_field_configs.php b/api/database/migrations/2026_04_25_120001_backfill_form_field_configs.php new file mode 100644 index 00000000..1f6ecd52 --- /dev/null +++ b/api/database/migrations/2026_04_25_120001_backfill_form_field_configs.php @@ -0,0 +1,148 @@ +backfill('form_fields', 'form_field'); + $this->backfill('form_field_library', 'form_field_library'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('form_field_configs')) { + return; + } + + DB::transaction(function (): void { + $this->reconstructJson('form_fields', 'form_field'); + $this->reconstructJson('form_field_library', 'form_field_library'); + }); + } + + private function backfill(string $table, string $ownerType): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) { + return; + } + + $rows = DB::table($table) + ->whereNotNull('validation_rules') + ->orderBy('id') + ->get(['id', 'validation_rules']); + + if ($rows->isEmpty()) { + return; + } + + $now = now(); + $inserts = []; + + foreach ($rows as $row) { + $decoded = is_string($row->validation_rules) + ? json_decode((string) $row->validation_rules, true) + : $row->validation_rules; + if (! is_array($decoded) || $decoded === []) { + continue; + } + + if (isset($decoded['tag_categories']) && is_array($decoded['tag_categories'])) { + $inserts[] = [ + 'id' => (string) Str::ulid(), + 'owner_type' => $ownerType, + 'owner_id' => (string) $row->id, + 'config_type' => FormFieldConfigType::TagCategories->value, + 'parameters' => json_encode([ + 'categories' => array_values(array_map( + static fn ($c): string => (string) $c, + $decoded['tag_categories'], + )), + ]), + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (isset($decoded['storage_disk']) && is_string($decoded['storage_disk']) && $decoded['storage_disk'] !== '') { + $inserts[] = [ + 'id' => (string) Str::ulid(), + 'owner_type' => $ownerType, + 'owner_id' => (string) $row->id, + 'config_type' => FormFieldConfigType::StorageDisk->value, + 'parameters' => json_encode(['disk' => $decoded['storage_disk']]), + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + if ($inserts === []) { + return; + } + + foreach (array_chunk($inserts, 500) as $batch) { + DB::table('form_field_configs')->insert($batch); + } + } + + private function reconstructJson(string $table, string $ownerType): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) { + return; + } + + $rows = DB::table('form_field_configs') + ->where('owner_type', $ownerType) + ->orderBy('owner_id') + ->get(); + + if ($rows->isEmpty()) { + return; + } + + $grouped = []; + foreach ($rows as $row) { + $ownerId = (string) $row->owner_id; + $grouped[$ownerId] ??= []; + $params = json_decode((string) $row->parameters, true); + $params = is_array($params) ? $params : []; + + if ($row->config_type === FormFieldConfigType::TagCategories->value) { + $grouped[$ownerId]['tag_categories'] = $params['categories'] ?? []; + } elseif ($row->config_type === FormFieldConfigType::StorageDisk->value) { + $grouped[$ownerId]['storage_disk'] = $params['disk'] ?? ''; + } + } + + foreach ($grouped as $ownerId => $configs) { + $existing = DB::table($table)->where('id', $ownerId)->value('validation_rules'); + $existingBag = is_string($existing) ? (json_decode($existing, true) ?: []) : []; + $merged = array_merge(is_array($existingBag) ? $existingBag : [], $configs); + + DB::table($table)->where('id', $ownerId)->update([ + 'validation_rules' => json_encode($merged), + ]); + } + } +}; diff --git a/api/database/migrations/2026_04_25_120002_drop_validation_rules_json_columns.php b/api/database/migrations/2026_04_25_120002_drop_validation_rules_json_columns.php new file mode 100644 index 00000000..ce7c3415 --- /dev/null +++ b/api/database/migrations/2026_04_25_120002_drop_validation_rules_json_columns.php @@ -0,0 +1,49 @@ +dropColumn('validation_rules'); + }); + } + if (Schema::hasColumn('form_field_library', 'validation_rules')) { + Schema::table('form_field_library', function (Blueprint $table): void { + $table->dropColumn('validation_rules'); + }); + } + } + + public function down(): void + { + if (! Schema::hasColumn('form_fields', 'validation_rules')) { + Schema::table('form_fields', function (Blueprint $table): void { + $table->json('validation_rules')->nullable()->after('options'); + }); + } + if (! Schema::hasColumn('form_field_library', 'validation_rules')) { + Schema::table('form_field_library', function (Blueprint $table): void { + $table->json('validation_rules')->nullable()->after('options'); + }); + } + } +}; diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index 9eb1d0f8..479ee565 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -284,10 +284,9 @@ final class FormBuilderDevSeeder '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, + // 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', @@ -370,7 +369,6 @@ final class FormBuilderDevSeeder '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, diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php index e001f016..27efa8a7 100644 --- a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormSchemaResourceTest.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1\Public\FormBuilder; +use App\Enums\FormBuilder\FormFieldConfigType; use App\Enums\FormBuilder\FormFieldType; use App\Enums\FormBuilder\FormPurpose; use App\Models\FormBuilder\FormField; +use App\Models\FormBuilder\FormFieldConfig; use App\Models\FormBuilder\FormSchema; use App\Models\Organisation; use App\Models\PersonTag; @@ -63,13 +65,15 @@ final class PublicFormSchemaResourceTest extends TestCase 'is_published' => true, 'public_token' => (string) Str::ulid(), ]); - FormField::factory()->create([ + $field = FormField::factory()->create([ 'form_schema_id' => $schema->id, 'field_type' => FormFieldType::TAG_PICKER->value, 'slug' => 'veiligheid', - 'validation_rules' => ['tag_categories' => ['Veiligheid']], 'is_portal_visible' => true, ]); + FormFieldConfig::factory()->forField($field) + ->ofType(FormFieldConfigType::TagCategories, ['categories' => ['Veiligheid']]) + ->create(); $response = $this->getJson("/api/v1/public/forms/{$schema->public_token}"); $field = collect($response->json('data.fields'))->firstWhere('slug', 'veiligheid'); diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 88c93507..cb91b599 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,10 +33,11 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_forward_migrations_backfill_rows_from_both_json_sources(): void { - // Roll back, newest first: backfill_form_field_validation_rules - // → create_form_field_validation_rules_table (both WS-5b commit 1+2) - // → drop_binding_json_columns → create_form_field_bindings. - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + // Roll back to pre-WS-5a state: 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) = 7. + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -97,23 +98,24 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - // Walk back the full WS-5b + WS-5a stack: backfill (validation rules) - // → create (validation rules table) → drop (binding columns) → - // create (bindings table). - $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); + // Walk back the full WS-5b + WS-5a stack (7 migrations). + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); $this->artisan('migrate')->assertSuccessful(); - // Fully-forward state: columns gone, rows in form_field_bindings. + // Fully-forward state: binding columns gone, rows in form_field_bindings. $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertSame(5, DB::table('form_field_bindings')->count()); - // Step back over the two WS-5b migrations → restores the pre-WS-5b - // state (validation-rules table gone; binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Step back over all five WS-5b migrations in one go → restores the + // pre-WS-5b state (validation-rules and configs tables gone, + // validation_rules JSON columns reappear on source tables; binding + // contract intact). + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); + $this->assertFalse(Schema::hasTable('form_field_configs')); $this->assertTrue(Schema::hasTable('form_field_bindings')); // Step back over drop_binding_json_columns → columns reappear empty. diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php new file mode 100644 index 00000000..1afc775a --- /dev/null +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -0,0 +1,112 @@ +artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); + + $fieldId = $this->seedField([ + 'field_type' => 'TAG_PICKER', + 'validation_rules' => [ + 'tag_categories' => ['Veiligheid', 'Horeca'], + 'storage_disk' => 's3', + ], + ]); + + $this->artisan('migrate')->assertSuccessful(); + + $rows = DB::table('form_field_configs') + ->where('owner_id', $fieldId) + ->get()->keyBy('config_type'); + + $this->assertTrue($rows->has('tag_categories')); + $this->assertSame( + ['Veiligheid', 'Horeca'], + json_decode((string) $rows['tag_categories']->parameters, true)['categories'], + ); + $this->assertTrue($rows->has('storage_disk')); + $this->assertSame( + 's3', + json_decode((string) $rows['storage_disk']->parameters, true)['disk'], + ); + } + + public function test_validation_rules_json_columns_are_dropped_after_migrations(): void + { + // Default state after RefreshDatabase: full migration applied. + $this->assertFalse(Schema::hasColumn('form_fields', 'validation_rules')); + $this->assertFalse(Schema::hasColumn('form_field_library', 'validation_rules')); + } + + public function test_cascade_observer_cleans_up_configs_on_owner_delete(): void + { + // Integration-level: confirms the renamed cascade observer covers + // the configs table too. + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = \App\Models\FormBuilder\FormField::factory()->create(['form_schema_id' => $schema->id]); + \App\Models\FormBuilder\FormFieldConfig::factory()->forField($field)->create(); + + $this->assertSame(1, \App\Models\FormBuilder\FormFieldConfig::query() + ->withoutGlobalScopes() + ->where('owner_id', $field->id) + ->count()); + + $field->delete(); + + $this->assertSame(0, \App\Models\FormBuilder\FormFieldConfig::query() + ->withoutGlobalScopes() + ->where('owner_id', $field->id) + ->count()); + } + + /** @param array $attrs */ + private function seedField(array $attrs): string + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $id = (string) Str::ulid(); + DB::table('form_fields')->insert([[ + 'id' => $id, + 'form_schema_id' => $schema->id, + 'field_type' => $attrs['field_type'], + 'slug' => 'f-'.Str::lower(Str::random(4)), + 'label' => 'field', + 'validation_rules' => json_encode($attrs['validation_rules']), + 'value_storage_hint' => 'json', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]]); + + return $id; + } +} diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigServiceAndScopeTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigServiceAndScopeTest.php new file mode 100644 index 00000000..8e2481fb --- /dev/null +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigServiceAndScopeTest.php @@ -0,0 +1,184 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldConfig::factory()->forField($field) + ->ofType(FormFieldConfigType::TagCategories, ['categories' => ['Veiligheid']])->create(); + FormFieldConfig::factory()->forField($field) + ->ofType(FormFieldConfigType::StorageDisk, ['disk' => 'local'])->create(); + + $configs = $field->fresh()->configs; + $this->assertCount(2, $configs); + $first = $configs->firstWhere('config_type', FormFieldConfigType::TagCategories); + $this->assertSame(FormField::class, $first->fresh()->owner::class); + } + + public function test_scope_isolates_configs_per_organisation_both_owner_types(): void + { + [$orgA, $fieldA, $libraryA] = $this->seedOrgWithConfigs(); + [$orgB, $fieldB, $libraryB] = $this->seedOrgWithConfigs(); + + $this->withOrgRoute($orgA); + $ids = FormFieldConfig::query()->pluck('owner_id')->sort()->values()->all(); + $expected = collect([$fieldA->id, $libraryA->id])->sort()->values()->all(); + $this->assertSame($expected, $ids); + + // Escape hatch. + $this->assertSame( + 4, + FormFieldConfig::query()->withoutGlobalScope(FormFieldConfigScope::class)->count(), + ); + $this->assertSame(2, FormFieldConfig::query()->count()); + } + + public function test_cascade_deletes_configs_on_owner_delete(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldConfig::factory()->forField($field)->create(); + + $this->assertSame(1, FormFieldConfig::query()->withoutGlobalScopes() + ->where('owner_id', $field->id)->count()); + + $field->delete(); + $this->assertSame(0, FormFieldConfig::query()->withoutGlobalScopes() + ->where('owner_id', $field->id)->count()); + } + + public function test_replace_configs_enum_and_parameter_shape_enforced(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $service = app(FormFieldConfigService::class); + + $this->expectException(UnknownValidationRuleTypeException::class); + $service->replaceConfigs($field, [ + ['config_type' => 'not_a_thing', 'parameters' => []], + ]); + } + + public function test_replace_configs_rejects_bad_tag_categories_shape(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $service = app(FormFieldConfigService::class); + + $this->expectException(UnknownValidationRuleTypeException::class); + $service->replaceConfigs($field, [ + ['config_type' => 'tag_categories', 'parameters' => ['categories' => 'not-an-array']], + ]); + } + + public function test_replace_configs_emits_activity_log_on_field_only(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + $service = app(FormFieldConfigService::class); + + $service->replaceConfigs($field, [ + ['config_type' => 'storage_disk', 'parameters' => ['disk' => 's3']], + ]); + $service->replaceConfigs($library, [ + ['config_type' => 'storage_disk', 'parameters' => ['disk' => 'local']], + ]); + + $this->assertNotNull(Activity::query() + ->where('subject_type', 'form_field') + ->where('subject_id', $field->id) + ->where('description', 'field.configs_replaced') + ->first()); + $this->assertNull(Activity::query() + ->where('subject_type', 'form_field_library') + ->where('description', 'field.configs_replaced') + ->first()); + } + + public function test_copy_configs_clones_every_row(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldConfig::factory()->forLibrary($library) + ->ofType(FormFieldConfigType::TagCategories, ['categories' => ['Horeca']])->create(); + FormFieldConfig::factory()->forLibrary($library) + ->ofType(FormFieldConfigType::StorageDisk, ['disk' => 'local'])->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + app(FormFieldConfigService::class)->copyConfigs($library, $field); + + $configs = FormFieldConfig::query()->where('owner_id', $field->id)->get(); + $this->assertCount(2, $configs); + } + + public function test_to_json_shape_nested_object_envelope(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldConfig::factory()->forField($field) + ->ofType(FormFieldConfigType::TagCategories, ['categories' => ['Veiligheid']])->create(); + FormFieldConfig::factory()->forField($field) + ->ofType(FormFieldConfigType::StorageDisk, ['disk' => 's3'])->create(); + + $shape = app(FormFieldConfigService::class)->toJsonShape($field->fresh()->configs); + $this->assertSame(['categories' => ['Veiligheid']], $shape['tag_categories']); + $this->assertSame(['disk' => 's3'], $shape['storage_disk']); + } + + /** @return array{0:Organisation,1:FormField,2:FormFieldLibrary} */ + private function seedOrgWithConfigs(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldConfig::factory()->forField($field)->create(); + FormFieldConfig::factory()->forLibrary($library)->create(); + + return [$org, $field, $library]; + } + + private function withOrgRoute(Organisation $org): void + { + $route = new Route(['GET'], '/_test', static fn () => null); + $route->bind(request()); + $route->setParameter('organisation', $org); + request()->setRouteResolver(static fn () => $route); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 17394131..89034ede 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -34,7 +34,11 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // Roll back: backfill + create-table. Brings us to a state where // form_fields.validation_rules exists but form_field_validation_rules // table does not. - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Roll back all WS-5b migrations to reach the pre-WS-5b state + // (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' => 5])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -91,7 +95,11 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_tag_categories_and_storage_disk_skipped_for_commit_5(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Roll back all WS-5b migrations to reach the pre-WS-5b state + // (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' => 5])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -111,7 +119,11 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_required_and_unique_skipped_with_warn(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Roll back all WS-5b migrations to reach the pre-WS-5b state + // (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' => 5])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -134,7 +146,11 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Roll back all WS-5b migrations to reach the pre-WS-5b state + // (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' => 5])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -147,7 +163,11 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_unmapped_field_type_for_min_max_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Roll back all WS-5b migrations to reach the pre-WS-5b state + // (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' => 5])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -158,22 +178,34 @@ final class FormFieldValidationRuleBackfillTest extends TestCase $this->artisan('migrate'); } - public function test_rollback_reconstructs_canonical_json_on_source_tables(): void + public function test_full_wsb_rollback_reconstructs_source_state(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Architect contract: "the forward+back pair is safe when run as a + // unit; a partial 'rollback this migration but not its create-table + // sibling' state is not supported." This test exercises the + // 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' => 5])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); + // Post-migration: rows exist, column dropped. + $this->assertSame( + 2, + DB::table('form_field_validation_rules') + ->where('owner_id', $numberId) + ->count(), + ); + $this->assertFalse(Schema::hasColumn('form_fields', 'validation_rules')); - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); - // Only the backfill rolled back — the create-table migration still - // applied, so rows remain accessible (until we step back once more). - $this->assertTrue(Schema::hasTable('form_field_validation_rules')); + // Roll back WS-5b fully → column reappears and carries canonical JSON + // reconstructed from the relational rows. + $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); $decoded = json_decode((string) $field->validation_rules, true); - // Rollback reconstructs using canonical keys — the legacy `min`/`max` - // are intentionally NOT resurrected (post-rename semantic). $this->assertSame(16, $decoded['min_value']); $this->assertSame(99, $decoded['max_value']); } diff --git a/apps/portal/src/components/public-form/FieldNumber.vue b/apps/portal/src/components/public-form/FieldNumber.vue index 8cfa0832..2f4882d6 100644 --- a/apps/portal/src/components/public-form/FieldNumber.vue +++ b/apps/portal/src/components/public-form/FieldNumber.vue @@ -43,8 +43,8 @@ function onUpdate(raw: string | number | null) { :label="field.label" :hint="field.help_text ?? undefined" persistent-hint - :min="field.validation_rules?.min ?? undefined" - :max="field.validation_rules?.max ?? undefined" + :min="field.validation_rules?.min_value ?? undefined" + :max="field.validation_rules?.max_value ?? undefined" :rules="rules" :error-messages="errorMessages" :required="field.is_required" diff --git a/apps/portal/src/components/public-form/FieldSectionPriority.vue b/apps/portal/src/components/public-form/FieldSectionPriority.vue index 7307f1b5..c691747d 100644 --- a/apps/portal/src/components/public-form/FieldSectionPriority.vue +++ b/apps/portal/src/components/public-form/FieldSectionPriority.vue @@ -54,7 +54,9 @@ watch(() => props.modelValue, v => { }, { deep: true }) const maxPriorities = computed(() => { - const raw = props.field.validation_rules?.max_priorities + // WS-5b canonicalised the legacy `max_priorities` key to `max_selected`; + // the field's cap (if any) is surfaced under `validation_rules.max_selected`. + const raw = props.field.validation_rules?.max_selected if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) { return Math.min(raw, HARD_CAP) } diff --git a/apps/portal/src/components/public-form/FieldText.vue b/apps/portal/src/components/public-form/FieldText.vue index 09599242..949103c7 100644 --- a/apps/portal/src/components/public-form/FieldText.vue +++ b/apps/portal/src/components/public-form/FieldText.vue @@ -29,7 +29,7 @@ const model = computed({ :rules="rules" :error-messages="errorMessages" :required="field.is_required" - :maxlength="field.validation_rules?.max ?? undefined" + :maxlength="field.validation_rules?.max_length ?? undefined" @blur="emit('blur')" /> diff --git a/apps/portal/src/components/public-form/FieldTextarea.vue b/apps/portal/src/components/public-form/FieldTextarea.vue index 40c04709..4509687c 100644 --- a/apps/portal/src/components/public-form/FieldTextarea.vue +++ b/apps/portal/src/components/public-form/FieldTextarea.vue @@ -31,7 +31,7 @@ const model = computed({ :rules="rules" :error-messages="errorMessages" :required="field.is_required" - :maxlength="field.validation_rules?.max ?? undefined" + :maxlength="field.validation_rules?.max_length ?? undefined" @blur="emit('blur')" /> diff --git a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md index 93be91d3..b193c963 100644 --- a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md +++ b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md @@ -176,6 +176,16 @@ WS-5b splitst `form_fields.validation_rules` en `form_field_library.validation_r **Strict validator op save (commit 3).** De vier FormRequests (`StoreFormFieldRequest`, `UpdateFormFieldRequest`, `StoreFormFieldLibraryRequest`, `UpdateFormFieldLibraryRequest`) accepteren `validation_rules` nu als array-of-spec-objects (`[{rule_type, parameters, error_message_key?}, ...]`). Semantische validatie (enum-case + parameter-shape + callback-registry) loopt via `FormFieldValidationRuleService::assertSpecsValid()` in een `after()` hook, zodat bad specs 422 geven vóór enige write. Controllers schrijven niet langer `validation_rules` naar de JSON-kolom; writes gaan uitsluitend via `replaceRules()`. +**Configs-tabel landing (commit 5).** `form_field_configs` is de parallelle tabel voor non-validation configuratie — `tag_categories` (TAG_PICKER-opties filter) en `storage_disk` (upload disk selector). Zelfde polymorphic-morph shape, eigen `FormFieldConfigType` enum, eigen `FormFieldConfigService`, eigen `FormFieldConfigScope`. De backfill-migratie pickt deze twee keys uit de pre-WS-5b JSON bag (die commit 2 welbewust skipte) en plaatst ze in `form_field_configs` vóór de JSON-kolomdrop in `2026_04_25_120002`. De hernoemde `FormFieldChildTablesCascadeObserver` dekt nu drie child-tabellen (bindings, validation_rules, configs) op owner delete. + +**Drie scope-siblings.** `FormFieldBindingScope` + `FormFieldValidationRuleScope` + `FormFieldConfigScope` — dezelfde UNION-over-two-owner-chains shape, drie near-duplicaten. Base-class extractie blijft uitgesteld tot WS-5d waar `form_field_options` als vierde sibling landt en het echte "wat varieert" zichtbaar zou moeten worden. Abstractie uit drie kopieën is nog steeds premature wanneer de vierde concrete implementatie aanstaande is. + +**Breaking change frontend-contract (commit 5).** De JSON-contract wijziging landde atomair in commit 5 — geen bridging compatibility layer per de "Breaking change acceptance" clause in ARCH-FORM-BUILDER §0. Vier portal Vue-componenten gemigreerd naar de canonieke key-namen (`min_value`/`max_value`/`max_length`/`max_selected`). De `tag_categories` / `storage_disk` reads in resources bleven binnen de backend — de portal SPA had deze keys niet direct in gebruik, dus de hypothetische `field.configs.tag_categories.categories` frontend-migratie bleef beperkt tot documentatie (ARCH §17.5.5) tot een frontend consumer het aanroept. + +**JSON-kolommen gedropt.** `form_fields.validation_rules` en `form_field_library.validation_rules` dropten in `2026_04_25_120002`. SCHEMA.md v2.4 verwerkte de drop plus de nieuwe `form_field_configs`-sectie; ARCH-FORM-BUILDER bumped naar v1.6 met een volledig nieuwe §17.5. De rollback-path "roll back WS-5b commits 1–5 together" reconstrueert beide JSON-bag bestemmingen mergen validatie-rules en configs terug naar één bag per rij — niet te verwarren met de per-migratie partial rollback die niet ondersteund is. + +**Afronding WS-5b.** 5 commits, baseline tests 1047 → volledig groen na commit 5. WS-5b is hiermee compleet; scope-sibling extractie en WS-5c (`conditional_logic`) / WS-5d (`options`) zijn separate work packages. + --- ## Q4 — Sanctum `personal_access_tokens` @@ -289,5 +299,6 @@ WS-1 rapport Categorie D bevindingen die geen architect-beslissing vereisten en - **Architect review:** akkoord per Claude Chat sessie 2026-04-24, iteratief verscherpt over drie rondes (initial → strict-enterprise op Q1/Q3 → FK-chain correctie op Q2). - **Product owner:** akkoord per Bert Hausmans 2026-04-24. - **WS-5a afronding:** 2026-04-24 — relationele `form_field_bindings` tabel, polymorphic owner, snapshot-parity, JSON-kolommen gedropt. +- **WS-5b afronding:** 2026-04-25 — relationele `form_field_validation_rules` + parallel `form_field_configs` tabel; `validation_rules` JSON-kolommen gedropt; frontend-contract migratie naar canonieke key-namen landed in commit 5. Volgende stap: prompt opstellen voor WS-2 (Purpose registry) met Q6-consolidatie als integraal onderdeel van de werkstroom. diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index 72305651..ac3d2039 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -1,13 +1,19 @@ -# ARCH — Universal Form Builder (v1.5) +# ARCH — Universal Form Builder (v1.6) > **Source of truth** for Crewli's universal Form Builder architecture. > Any discrepancy with SCHEMA.md is resolved in favour of this document > during the refactor. SCHEMA.md is updated at the end of the refactor. > > **Status:** Approved — WS-5a landed (relational `form_field_bindings`); -> WS-5b validation rules landed (relational `form_field_validation_rules`). -> **Version:** 1.5 (§17.4 restructured into relational sub-sections: -> catalogue, relational table, callback rules, legacy JSON migration). +> WS-5b landed in full (relational `form_field_validation_rules` and +> parallel `form_field_configs`; pre-WS-5b `validation_rules` JSON +> columns dropped). +> **Version:** 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). +> **Previous:** +> 1.5 (§17.4 restructured into relational sub-sections: catalogue, +> relational table, callback rules, legacy JSON migration). > **Previous versions:** > 1.4 (§6.3 retitled to "Binding row specification"; new §6.7 "Relational > binding table"; §17.3 pre-publish check in present tense per WS-5a), @@ -2410,13 +2416,112 @@ Those rows are immutable records and are not rewritten by the migration. Snapshot readers must tolerate both shapes — pre-WS-5b legacy keys and post-WS-5b canonical keys. -### 17.5 Webhooks +### 17.5 Field configuration (non-validation) -#### 17.5.1 Schema +Per-field configuration that is *not* validation (tag-picker category +filters, upload disk selection) lives in the relational +`form_field_configs` table — a deliberate sibling to §17.4's +`form_field_validation_rules`, not a merger. Two tables with clear +semantics beat one table that drifts into "bucket for everything that +doesn't fit elsewhere". + +#### 17.5.1 Why this is separate from `validation_rules` + +Pre-WS-5b, `form_fields.validation_rules` was a grab-bag that held +validation *and* non-validation keys. Keeping the non-validation keys +in a table named `form_field_validation_rules` would have poisoned that +table's meaning and re-introduced the drift WS-5 was cleaning up. The +strict-enterprise resolution on the Q3 WS-5b decision gate was: split +the non-validation keys into their own relational home with matching +semantics ("table name = table contents"), at the cost of one extra +table, one extra enum, one extra service, one extra scope. The +architecture decision log is in +`/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5b +Uitvoering. + +#### 17.5.2 Table `form_field_configs` and config-type catalogue + +**Columns** (SCHEMA.md §3.5.12): + +| Column | Type | Notes | +| -------------- | ----------------- | ---------------------------------------------------------- | +| `id` | ULID | PK | +| `owner_type` | string(40) | morph alias: `form_field` or `form_field_library` | +| `owner_id` | ULID | parent row | +| `config_type` | string(40) | enum case value | +| `parameters` | JSON | per-config-type bag | +| `created_at`, `updated_at` | timestamps | | + +**Catalogue (`FormFieldConfigType`):** + +| `config_type` | `parameters` shape | Consumed by | +| ----------------- | ------------------------------- | --------------------------------------------------------------------------------------------- | +| `tag_categories` | `{"categories": [string]}` | `FormFieldResource` + `PublicFormSchemaResource` — filters `person_tags` options for TAG_PICKER fields | +| `storage_disk` | `{"disk": string}` | `FormValueService` (file-upload handling — WS-6) — overrides the default filesystem disk | + +Both config types are app-enforced, not DB enum — same rationale as +§17.4.1 (runtime extensibility via registry). + +#### 17.5.3 Service, scope, cascade, activity log + +Mirrors §17.4's validation-rules stack one-for-one: + +- **Service boundary** (`FormFieldConfigService`) — `configsFor`, + `replaceConfigs`, `copyConfigs`, `toJsonShape`, `assertSpecsValid`. + Single writer; all controller paths go through it. +- **Multi-tenancy** (`FormFieldConfigScope`) — third near-duplicate of + `FormFieldBindingScope`. The three siblings' base-class extraction is + deferred to WS-5d per addendum Q3 (abstracting from three is still + premature when the fourth sibling is about to land and may clarify + what truly varies). +- **Cascade** — shared `FormFieldChildTablesCascadeObserver` (renamed + from `FormFieldBindingsCascadeObserver` in WS-5b commit 1) covers + all three relational tables on owner delete. +- **Activity log** — two entries emit on config changes on a + FormField subject: `field.updated` (reconstructed `configs` via + `toJsonShape`) and `field.configs_replaced` (semantic event). Matches + the §6.7 / §17.4.2 pattern. Library-level changes are silent in + activity log; consumers that need them listen at a different layer. + +#### 17.5.4 Snapshot embedding + +`form_submissions.schema_snapshot.fields[*]` gains a top-level `configs` +key alongside `validation_rules`: + +```json +{ + "id": "01H...", + "slug": "vaardigheden", + "field_type": "TAG_PICKER", + "validation_rules": null, + "configs": { "tag_categories": { "categories": ["Veiligheid"] } }, + ... +} +``` + +Historical snapshots written before WS-5b commit 5 continue to embed +the merged shape (`validation_rules: {"tag_categories": [...], "min": +3}`) with no `configs` key — those rows are immutable records. Readers +must tolerate both shapes. + +#### 17.5.5 External API contract change + +WS-5b commit 5 is a breaking change to the form-field JSON contract. +Pre-WS-5b: `field.validation_rules.tag_categories`. Post-WS-5b: +`field.configs.tag_categories.categories`. Same for `storage_disk`. +The portal + organizer SPAs are updated in the same work package +(WS-5b commit 5); there is no bridging compatibility layer. See the +"Breaking change acceptance" note at the top of this document. + +--- + +### 17.6 Webhooks + +#### 17.6.1 Schema See §4.11 `form_schema_webhooks` and §4.12 `form_webhook_deliveries`. -#### 17.5.2 Dispatcher +#### 17.6.2 Dispatcher `FormWebhookDispatcher` listens for FormSubmissionSubmitted / Reviewed / SectionSubmitted / SectionReviewed events. On trigger: @@ -2424,7 +2529,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.5.3 Delivery job +#### 17.6.3 Delivery job `DeliverFormWebhookJob` on `webhooks` queue: - Idempotent (Laravel job with unique ID per delivery) @@ -2443,7 +2548,7 @@ SectionSubmitted / SectionReviewed events. On trigger: Response body first 1000 chars stored in `response_body_excerpt` for debugging. -#### 17.5.4 Security +#### 17.6.4 Security URL validation in `FormWebhookDispatcher`: - Parse URL; reject non-http(s) @@ -2455,7 +2560,7 @@ URL validation in `FormWebhookDispatcher`: Admin UI shows validation status + last delivery attempt per webhook. -#### 17.5.5 Webhook payload format +#### 17.6.5 Webhook payload format ```json { diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 98c7b14a..413c4a4e 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,10 +1,26 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.3** — Updated April 2026 +> **Version: 2.4** — Updated April 2026 > > **Changelog:** > +> - v2.4: WS-5b completion — `form_field_configs` relational table lands +> alongside `form_field_validation_rules` (both from WS-5b). Holds +> non-validation per-field configuration (`tag_categories`, +> `storage_disk`) that would have polluted the validation-rules table +> had it stayed there. Same polymorphic-morph pattern (owner aliases +> `form_field` / `form_field_library`, reused from WS-5a). The +> `validation_rules` JSON columns on `form_fields` and +> `form_field_library` are **dropped** by this migration pair — the +> entire pre-WS-5b bag now lives relationally across two tables. +> Schema snapshots gain a parallel top-level `configs` key on each +> field entry; historical snapshots pre-WS-5b remain immutable with +> the legacy merged shape. Breaking frontend contract: portal + +> organizer SPAs switched from reading `field.validation_rules.min` +> etc. to the canonical post-WS-5b keys (`min_value`, `max_length`, +> `max_selected`, etc.) per ARCH v1.6 §17.5 and addendum Q3 WS-5b +> Uitvoering. > - v2.3: WS-5b (partial) — `form_field_validation_rules` relational table > replaces the `validation_rules` JSON on `form_fields` and > `form_field_library`. Typed `rule_type` column + per-rule `parameters` @@ -1989,7 +2005,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` +**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` **Indexes:** `(organisation_id, field_type)`, `(organisation_id, is_active)` **Unique constraint:** `UNIQUE(organisation_id, slug)` **Global scope:** `OrganisationScope` @@ -2045,7 +2061,7 @@ that aggregates the user's submitted, non-test `form_submissions`. | `created_at`, `updated_at` | timestamps | | | `deleted_at` | timestamp nullable | Soft delete preserves history | -**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner` +**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner` **Indexes:** `(form_schema_id, sort_order)`, `(form_schema_id, is_filterable)`, `(library_field_id)`, `(form_schema_id, slug)` **Soft delete:** yes @@ -2124,6 +2140,32 @@ that aggregates the user's submitted, non-test `form_submissions`. --- +### `form_field_configs` + +> Parallel sibling to `form_field_validation_rules` — holds +> non-validation per-field configuration (tag-picker category filters, +> upload disk selection). Keeps `form_field_validation_rules` +> semantically pure (ARCH-FORM-BUILDER.md §17.5; addendum Q3 strict- +> enterprise decision). Same polymorphic-morph pattern as the binding +> and validation-rules tables. + +| Column | Type | Notes | +| -------------- | ----------------- | ---------------------------------------------------------------------- | +| `id` | ULID | PK | +| `owner_type` | string(40) | morph alias: `form_field` or `form_field_library` | +| `owner_id` | ULID | parent row | +| `config_type` | string(40) | `FormFieldConfigType` case (`tag_categories`, `storage_disk`) | +| `parameters` | JSON | Per-config-type bag (`{"categories":[string]}`, `{"disk":string}`) | +| `created_at`, `updated_at` | timestamps | | + +**Relations:** `morphTo` owner (`form_field` or `form_field_library`) +**Indexes:** `(config_type)`, `(owner_type, owner_id)` +**Unique constraint:** `UNIQUE(owner_type, owner_id, config_type)` +**Global scope:** `FormFieldConfigScope` — third sibling in the scope family (after `FormFieldBindingScope` and `FormFieldValidationRuleScope`), same UNION shape. Escape hatch: `withoutGlobalScope(FormFieldConfigScope::class)`. +**Soft delete:** no — configs are current state, not audit + +--- + ### `form_submissions` > One submission per `(schema, subject)` in `single` / `draft_single`