From 2064b9901ebe8e0618864a44fbb02d101929d687 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 23:43:34 +0200 Subject: [PATCH] feat(form-builder): form_field_conditional_logic_{groups,conditions} tables + OrganisationScope cap raise to 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS-5c commit 1 of 4 — relational infrastructure for the conditional- logic tree that replaces form_fields.conditional_logic JSON (ARCH- FORM-BUILDER §8; addendum Q3 WS-5c). Tables: groups (nesting via parent_group_id) + conditions (leaves, value JSON nullable for empty/not_empty). Simple FK to form_fields — addendum Q3 explicitly excludes form_field_library from conditional_ logic scope, so no polymorphic morph here. OrganisationScope cap raised 3 → 5 hops. The conditions chain is 4 hops (condition → group → field → schema → organisation_id column) and the new cap gives headroom for future deeper trees without denormalising form_field_id onto conditions. Cascade observer (FormFieldChildTablesCascadeObserver) extended to physically delete the new groups table on FormField delete (hard or soft). Conditions cascade automatically via the group_id FK on the groups table. Factories: FormFieldConditionalLogicGroupFactory, FormFieldConditional LogicConditionFactory, and FormFieldFactory::withConditionalLogic($tree) for concise test fixtures. Tests: 16 new under tests/Feature/FormBuilder/ConditionalLogic/ (relation, scope, cascade, enum catalogue). 3 new scope-cap tests in ScopeLeakageTest verify 4/5-hop chains pass and 6-hop throws. Hardcoded rollback step counts in WS-5a/b migration tests bumped for the 2 new WS-5c migrations. Baseline 1104 → 1122 green (2988 → 3032 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...FieldConditionalLogicConditionOperator.php | 44 +++++ ...FormFieldConditionalLogicGroupOperator.php | 17 ++ .../TenantScopeResolutionException.php | 8 +- api/app/Models/FormBuilder/FormField.php | 17 ++ .../FormFieldConditionalLogicCondition.php | 64 ++++++ .../FormFieldConditionalLogicGroup.php | 76 ++++++++ api/app/Models/Scopes/OrganisationScope.php | 10 +- .../FormFieldChildTablesCascadeObserver.php | 31 ++- ...mFieldConditionalLogicConditionFactory.php | 53 +++++ .../FormFieldConditionalLogicGroupFactory.php | 50 +++++ .../FormBuilder/FormFieldFactory.php | 81 ++++++++ ...m_field_conditional_logic_groups_table.php | 49 +++++ ...eld_conditional_logic_conditions_table.php | 44 +++++ .../FormFieldBindingMigrationTest.php | 27 +-- .../FormFieldConditionalLogicCascadeTest.php | 80 ++++++++ ...dConditionalLogicConditionRelationTest.php | 122 ++++++++++++ ...FieldConditionalLogicGroupRelationTest.php | 109 +++++++++++ .../FormFieldConditionalLogicScopeTest.php | 103 ++++++++++ .../FormFieldConfigBackfillAndDropTest.php | 7 +- .../FormFieldValidationRuleBackfillTest.php | 27 ++- .../Feature/MultiTenancy/ScopeLeakageTest.php | 183 ++++++++++++++++-- 21 files changed, 1140 insertions(+), 62 deletions(-) create mode 100644 api/app/Enums/FormBuilder/FormFieldConditionalLogicConditionOperator.php create mode 100644 api/app/Enums/FormBuilder/FormFieldConditionalLogicGroupOperator.php create mode 100644 api/app/Models/FormBuilder/FormFieldConditionalLogicCondition.php create mode 100644 api/app/Models/FormBuilder/FormFieldConditionalLogicGroup.php create mode 100644 api/database/factories/FormBuilder/FormFieldConditionalLogicConditionFactory.php create mode 100644 api/database/factories/FormBuilder/FormFieldConditionalLogicGroupFactory.php create mode 100644 api/database/migrations/2026_04_26_100000_create_form_field_conditional_logic_groups_table.php create mode 100644 api/database/migrations/2026_04_26_100001_create_form_field_conditional_logic_conditions_table.php create mode 100644 api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicCascadeTest.php create mode 100644 api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicConditionRelationTest.php create mode 100644 api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicGroupRelationTest.php create mode 100644 api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicScopeTest.php diff --git a/api/app/Enums/FormBuilder/FormFieldConditionalLogicConditionOperator.php b/api/app/Enums/FormBuilder/FormFieldConditionalLogicConditionOperator.php new file mode 100644 index 00000000..304c1c5f --- /dev/null +++ b/api/app/Enums/FormBuilder/FormFieldConditionalLogicConditionOperator.php @@ -0,0 +1,44 @@ + + */ + public static function valuelessCases(): array + { + return [self::Empty, self::NotEmpty]; + } + + public function isValueless(): bool + { + return in_array($this, self::valuelessCases(), true); + } +} diff --git a/api/app/Enums/FormBuilder/FormFieldConditionalLogicGroupOperator.php b/api/app/Enums/FormBuilder/FormFieldConditionalLogicGroupOperator.php new file mode 100644 index 00000000..2f2a3c19 --- /dev/null +++ b/api/app/Enums/FormBuilder/FormFieldConditionalLogicGroupOperator.php @@ -0,0 +1,17 @@ +morphMany(FormFieldConfig::class, 'owner'); } + public function conditionalLogicGroups(): HasMany + { + return $this->hasMany(FormFieldConditionalLogicGroup::class, 'form_field_id'); + } + + /** + * The tree root: the group with `parent_group_id IS NULL`. Fields + * without any conditional logic return null. Only one root is + * supported per field — enforced by the service layer's `replaceLogic`. + */ + public function rootConditionalLogicGroup(): ?FormFieldConditionalLogicGroup + { + return $this->conditionalLogicGroups() + ->whereNull('parent_group_id') + ->first(); + } + /** * 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/FormFieldConditionalLogicCondition.php b/api/app/Models/FormBuilder/FormFieldConditionalLogicCondition.php new file mode 100644 index 00000000..c035a5c0 --- /dev/null +++ b/api/app/Models/FormBuilder/FormFieldConditionalLogicCondition.php @@ -0,0 +1,64 @@ + FormFieldConditionalLogicGroup::class, 'fk' => 'group_id']; + } + + protected $fillable = [ + 'group_id', + 'field_slug', + 'comparison_operator', + 'value', + 'sort_order', + ]; + + /** @var array */ + protected $casts = [ + 'comparison_operator' => FormFieldConditionalLogicConditionOperator::class, + 'value' => 'array', + 'sort_order' => 'int', + ]; + + public function group(): BelongsTo + { + return $this->belongsTo(FormFieldConditionalLogicGroup::class, 'group_id'); + } +} diff --git a/api/app/Models/FormBuilder/FormFieldConditionalLogicGroup.php b/api/app/Models/FormBuilder/FormFieldConditionalLogicGroup.php new file mode 100644 index 00000000..84036295 --- /dev/null +++ b/api/app/Models/FormBuilder/FormFieldConditionalLogicGroup.php @@ -0,0 +1,76 @@ + FormField::class, 'fk' => 'form_field_id']; + } + + protected $fillable = [ + 'form_field_id', + 'parent_group_id', + 'operator', + 'sort_order', + ]; + + /** @var array */ + protected $casts = [ + 'operator' => FormFieldConditionalLogicGroupOperator::class, + 'sort_order' => 'int', + ]; + + public function formField(): BelongsTo + { + return $this->belongsTo(FormField::class, 'form_field_id'); + } + + public function parentGroup(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_group_id'); + } + + public function childGroups(): HasMany + { + return $this->hasMany(self::class, 'parent_group_id'); + } + + public function conditions(): HasMany + { + return $this->hasMany(FormFieldConditionalLogicCondition::class, 'group_id'); + } +} diff --git a/api/app/Models/Scopes/OrganisationScope.php b/api/app/Models/Scopes/OrganisationScope.php index 957c4dfa..6c17c0a6 100644 --- a/api/app/Models/Scopes/OrganisationScope.php +++ b/api/app/Models/Scopes/OrganisationScope.php @@ -26,9 +26,15 @@ use Illuminate\Database\Eloquent\Scope; * ['column' => 'organisation_id'] — direct scope column, OR * ['via' => ParentModel::class, 'fk' => 'parent_id'] — walk to parent * The resolver recurses through parents until it hits a column strategy - * or the legacy $organisationScopeColumn bridge. Cap at 3 hops; deeper + * or the legacy $organisationScopeColumn bridge. Cap at 5 hops; deeper * chains raise TenantScopeResolutionException. * + * Cap raised from 3 to 5 in WS-5c (2026-04-26). Legitimate relational + * trees — `form_field_conditional_logic_conditions → group → field → + * schema → org` — exceed the original limit. Raising to 5 accommodates + * this without denormalising `form_field_id` onto conditions (which + * would introduce drift risk). + * * (b) Legacy column property (pre-addendum — still supported so Person, * Event, FestivalSection, etc. need not migrate in this work package): * `public string $organisationScopeColumn = 'organisation_id' @@ -43,7 +49,7 @@ use Illuminate\Database\Eloquent\Scope; */ final class OrganisationScope implements Scope { - private const MAX_CHAIN_HOPS = 3; + private const MAX_CHAIN_HOPS = 5; public function __construct( private readonly ?string $organisationId = null, diff --git a/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php index c6468df6..081d1774 100644 --- a/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php +++ b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php @@ -10,22 +10,28 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; /** - * Cascades physical deletion of child rows in the three relational tables + * Cascades physical deletion of child rows in the relational tables * that hang off `form_fields` / `form_field_library`: * - * - `form_field_bindings` (WS-5a) - * - `form_field_validation_rules` (WS-5b) - * - `form_field_configs` (WS-5b expansion; added in commit 5) + * - `form_field_bindings` (WS-5a) + * - `form_field_validation_rules` (WS-5b) + * - `form_field_configs` (WS-5b commit 5) + * - `form_field_conditional_logic_groups` (WS-5c — FormField only, + * per addendum Q3: library + * is explicitly out of + * scope for conditional_logic) + * + * The conditions sibling table cascades automatically via the `group_id` + * FK on the groups table — no direct entry here. * * Children represent current state (not historical intent) — physically * deleted even when the owner is only soft-deleted, per addendum Q3: no * child table in this family carries a soft-delete semantic of its own. * * Renamed from `FormFieldBindingsCascadeObserver` during WS-5b commit 1. - * The `Schema::hasTable` guard on the validation-rules cleanup keeps the - * observer safe when this code runs against a database where commit 1's - * migration has not yet been applied (e.g. during the migration step - * itself, where the observer is registered before the table exists). + * The `Schema::hasTable` guard keeps the observer safe when this code + * runs against a database where a later migration has not yet been + * applied (e.g. during the migration step itself). */ final class FormFieldChildTablesCascadeObserver { @@ -52,5 +58,14 @@ final class FormFieldChildTablesCascadeObserver ->where('owner_id', $ownerId) ->delete(); } + + // Conditional-logic groups only apply to FormField (addendum Q3: + // library mirror is out of scope). Condition rows cascade via + // the group_id FK on the conditions table. + if ($owner instanceof FormField && Schema::hasTable('form_field_conditional_logic_groups')) { + DB::table('form_field_conditional_logic_groups') + ->where('form_field_id', $ownerId) + ->delete(); + } } } diff --git a/api/database/factories/FormBuilder/FormFieldConditionalLogicConditionFactory.php b/api/database/factories/FormBuilder/FormFieldConditionalLogicConditionFactory.php new file mode 100644 index 00000000..5fa17fdf --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldConditionalLogicConditionFactory.php @@ -0,0 +1,53 @@ + */ +final class FormFieldConditionalLogicConditionFactory extends Factory +{ + protected $model = FormFieldConditionalLogicCondition::class; + + /** @return array */ + public function definition(): array + { + return [ + 'group_id' => FormFieldConditionalLogicGroup::factory(), + 'field_slug' => 'gate', + 'comparison_operator' => FormFieldConditionalLogicConditionOperator::Equals->value, + 'value' => 'yes', + 'sort_order' => 0, + ]; + } + + public function inGroup(FormFieldConditionalLogicGroup $group, int $sortOrder = 0): static + { + return $this->state(fn () => [ + 'group_id' => $group->id, + 'sort_order' => $sortOrder, + ]); + } + + public function withOperator( + FormFieldConditionalLogicConditionOperator $operator, + mixed $value = null, + ): static { + return $this->state(fn () => [ + 'comparison_operator' => $operator->value, + 'value' => $operator->isValueless() ? null : $value, + ]); + } + + public function forFieldSlug(string $slug): static + { + return $this->state(fn () => [ + 'field_slug' => $slug, + ]); + } +} diff --git a/api/database/factories/FormBuilder/FormFieldConditionalLogicGroupFactory.php b/api/database/factories/FormBuilder/FormFieldConditionalLogicGroupFactory.php new file mode 100644 index 00000000..9acf724d --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldConditionalLogicGroupFactory.php @@ -0,0 +1,50 @@ + */ +final class FormFieldConditionalLogicGroupFactory extends Factory +{ + protected $model = FormFieldConditionalLogicGroup::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_field_id' => FormField::factory(), + 'parent_group_id' => null, + 'operator' => FormFieldConditionalLogicGroupOperator::All->value, + 'sort_order' => 0, + ]; + } + + public function forField(FormField $field): static + { + return $this->state(fn () => [ + 'form_field_id' => $field->id, + ]); + } + + public function withOperator(FormFieldConditionalLogicGroupOperator $operator): static + { + return $this->state(fn () => [ + 'operator' => $operator->value, + ]); + } + + public function nestedUnder(FormFieldConditionalLogicGroup $parent, int $sortOrder = 0): static + { + return $this->state(fn () => [ + 'form_field_id' => $parent->form_field_id, + 'parent_group_id' => $parent->id, + 'sort_order' => $sortOrder, + ]); + } +} diff --git a/api/database/factories/FormBuilder/FormFieldFactory.php b/api/database/factories/FormBuilder/FormFieldFactory.php index 59d426d7..c2895eb4 100644 --- a/api/database/factories/FormBuilder/FormFieldFactory.php +++ b/api/database/factories/FormBuilder/FormFieldFactory.php @@ -5,11 +5,15 @@ declare(strict_types=1); namespace Database\Factories\FormBuilder; use App\Enums\FormBuilder\FormFieldBindingMode; +use App\Enums\FormBuilder\FormFieldConditionalLogicConditionOperator; +use App\Enums\FormBuilder\FormFieldConditionalLogicGroupOperator; use App\Enums\FormBuilder\FormFieldDisplayWidth; use App\Enums\FormBuilder\FormFieldType; use App\Enums\FormBuilder\FormFieldValidationRuleType; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormFieldBinding; +use App\Models\FormBuilder\FormFieldConditionalLogicCondition; +use App\Models\FormBuilder\FormFieldConditionalLogicGroup; use App\Models\FormBuilder\FormFieldValidationRule; use App\Models\FormBuilder\FormSchema; use Illuminate\Database\Eloquent\Factories\Factory; @@ -118,4 +122,81 @@ final class FormFieldFactory extends Factory ->create(); }); } + + /** + * Build a conditional-logic tree for this field after persistence. + * `$tree` mirrors the canonical ARCH §8 JSON shape without the + * outer `show_when` wrapper — the root must be a group. + * + * Example: + * + * $factory->withConditionalLogic([ + * 'operator' => 'all', + * 'children' => [ + * ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + * [ + * 'operator' => 'any', + * 'children' => [ + * ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + * ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'], + * ], + * ], + * ], + * ]); + * + * @param array $tree + */ + public function withConditionalLogic(array $tree): static + { + return $this->afterCreating(function (FormField $field) use ($tree): void { + self::buildLogicTree($field->id, null, $tree, 0); + }); + } + + /** + * Recursive walker used only by `withConditionalLogic`. The service + * layer (WS-5c commit 2) owns the canonical write path; this helper + * stays minimal and keeps factories self-contained. + * + * @param array $node + */ + private static function buildLogicTree(string $fieldId, ?string $parentId, array $node, int $sortOrder): void + { + if (isset($node['field_slug'])) { + /** @var string $slug */ + $slug = $node['field_slug']; + $rawOperator = isset($node['operator']) && is_string($node['operator']) + ? $node['operator'] + : FormFieldConditionalLogicConditionOperator::Equals->value; + $operator = FormFieldConditionalLogicConditionOperator::from($rawOperator); + + FormFieldConditionalLogicCondition::factory()->create([ + 'group_id' => $parentId, + 'field_slug' => $slug, + 'comparison_operator' => $operator->value, + 'value' => $operator->isValueless() ? null : ($node['value'] ?? null), + 'sort_order' => $sortOrder, + ]); + + return; + } + + $rawOperator = isset($node['operator']) && is_string($node['operator']) + ? $node['operator'] + : FormFieldConditionalLogicGroupOperator::All->value; + $groupOperator = FormFieldConditionalLogicGroupOperator::from($rawOperator); + + $group = FormFieldConditionalLogicGroup::factory()->create([ + 'form_field_id' => $fieldId, + 'parent_group_id' => $parentId, + 'operator' => $groupOperator->value, + 'sort_order' => $sortOrder, + ]); + + /** @var array> $children */ + $children = $node['children'] ?? []; + foreach ($children as $childSortOrder => $child) { + self::buildLogicTree($fieldId, $group->id, $child, $childSortOrder); + } + } } diff --git a/api/database/migrations/2026_04_26_100000_create_form_field_conditional_logic_groups_table.php b/api/database/migrations/2026_04_26_100000_create_form_field_conditional_logic_groups_table.php new file mode 100644 index 00000000..b32aa3c1 --- /dev/null +++ b/api/database/migrations/2026_04_26_100000_create_form_field_conditional_logic_groups_table.php @@ -0,0 +1,49 @@ +ulid('id')->primary(); + $table->foreignUlid('form_field_id') + ->constrained('form_fields') + ->cascadeOnDelete(); + $table->foreignUlid('parent_group_id') + ->nullable() + ->constrained('form_field_conditional_logic_groups') + ->cascadeOnDelete(); + $table->string('operator', 10); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index('form_field_id', 'ffclg_field_idx'); + $table->index(['parent_group_id', 'sort_order'], 'ffclg_parent_sort_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('form_field_conditional_logic_groups'); + } +}; diff --git a/api/database/migrations/2026_04_26_100001_create_form_field_conditional_logic_conditions_table.php b/api/database/migrations/2026_04_26_100001_create_form_field_conditional_logic_conditions_table.php new file mode 100644 index 00000000..38e25566 --- /dev/null +++ b/api/database/migrations/2026_04_26_100001_create_form_field_conditional_logic_conditions_table.php @@ -0,0 +1,44 @@ +ulid('id')->primary(); + $table->foreignUlid('group_id') + ->constrained('form_field_conditional_logic_groups') + ->cascadeOnDelete(); + $table->string('field_slug', 100); + $table->string('comparison_operator', 20); + $table->json('value')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['group_id', 'sort_order'], 'ffclc_group_sort_idx'); + $table->index('field_slug', 'ffclc_field_slug_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('form_field_conditional_logic_conditions'); + } +}; diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index cb91b599..aef74cda 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,11 +33,12 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_forward_migrations_backfill_rows_from_both_json_sources(): void { - // 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(); + // Roll back to pre-WS-5a state: 2 WS-5c migrations + // (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) = 9. + $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -98,8 +99,8 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - // Walk back the full WS-5b + WS-5a stack (7 migrations). - $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + // Walk back the full WS-5c + WS-5b + WS-5a stack (9 migrations). + $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -109,11 +110,13 @@ final class FormFieldBindingMigrationTest extends TestCase $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertSame(5, DB::table('form_field_bindings')->count()); - // 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(); + // Step back over WS-5c (2 migrations) + WS-5b (5 migrations) in one + // go → restores the pre-WS-5b state (conditional-logic, + // validation-rules and configs tables gone, validation_rules JSON + // columns reappear on source tables; binding contract intact). + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); + $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertFalse(Schema::hasTable('form_field_configs')); $this->assertTrue(Schema::hasTable('form_field_bindings')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicCascadeTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicCascadeTest.php new file mode 100644 index 00000000..fff45a61 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicCascadeTest.php @@ -0,0 +1,80 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $root = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + $child = FormFieldConditionalLogicGroup::factory()->nestedUnder($root, 0)->create(); + FormFieldConditionalLogicCondition::factory()->inGroup($root, 0)->create(); + FormFieldConditionalLogicCondition::factory()->inGroup($child, 0)->create(); + + $this->assertSame(2, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(2, FormFieldConditionalLogicCondition::query()->count()); + + // Hard delete via raw query bypasses SoftDeletes — exercises the + // DB-level `ON DELETE CASCADE` on `form_field_id`. + DB::table('form_fields')->where('id', $field->id)->delete(); + + $this->assertSame(0, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(0, FormFieldConditionalLogicCondition::query()->count()); + } + + public function test_field_soft_delete_cascades_via_observer(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $root = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + FormFieldConditionalLogicCondition::factory()->inGroup($root, 0)->create(); + + $field->delete(); + + // Soft delete keeps the `form_fields` row (deleted_at set) but the + // cascade observer physically clears child rows — conditional-logic + // state is current state, not audit. + $this->assertNotNull($field->fresh()->deleted_at); + $this->assertSame(0, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(0, FormFieldConditionalLogicCondition::query()->count()); + } + + public function test_parent_group_delete_cascades_to_children_and_conditions(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $root = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + $child = FormFieldConditionalLogicGroup::factory()->nestedUnder($root, 0)->create(); + FormFieldConditionalLogicCondition::factory()->inGroup($root, 0)->create(); + FormFieldConditionalLogicCondition::factory()->inGroup($child, 0)->create(); + + $this->assertSame(2, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(2, FormFieldConditionalLogicCondition::query()->count()); + + $root->delete(); + + $this->assertSame(0, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(0, FormFieldConditionalLogicCondition::query()->count()); + } +} diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicConditionRelationTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicConditionRelationTest.php new file mode 100644 index 00000000..0420d373 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicConditionRelationTest.php @@ -0,0 +1,122 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $group = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + $condition = FormFieldConditionalLogicCondition::factory()->inGroup($group)->create(); + + $this->assertSame($group->id, $condition->fresh()->group->id); + } + + public function test_value_roundtrips_through_json_cast(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $group = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + + $stringValue = FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->withOperator(FormFieldConditionalLogicConditionOperator::Equals, 'NL') + ->create(); + $this->assertSame('NL', $stringValue->fresh()->value); + + $arrayValue = FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->withOperator(FormFieldConditionalLogicConditionOperator::In, ['a', 'b', 'c']) + ->create(); + $this->assertSame(['a', 'b', 'c'], $arrayValue->fresh()->value); + + $boolValue = FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->withOperator(FormFieldConditionalLogicConditionOperator::Equals, true) + ->create(); + $this->assertTrue($boolValue->fresh()->value); + } + + public function test_valueless_operators_store_null(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $group = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + + $empty = FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->withOperator(FormFieldConditionalLogicConditionOperator::Empty) + ->create(); + $this->assertNull($empty->fresh()->value); + + $notEmpty = FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->withOperator(FormFieldConditionalLogicConditionOperator::NotEmpty) + ->create(); + $this->assertNull($notEmpty->fresh()->value); + } + + public function test_enum_catalogue_has_ten_operators(): void + { + // Parity check against ARCH §8 / Phase A seed-scan confirmed set. + $values = array_map( + fn (FormFieldConditionalLogicConditionOperator $case): string => $case->value, + FormFieldConditionalLogicConditionOperator::cases(), + ); + + sort($values); + $this->assertSame([ + 'contains', + 'empty', + 'equals', + 'greater_than', + 'in', + 'less_than', + 'not_contains', + 'not_empty', + 'not_equals', + 'not_in', + ], $values); + } + + public function test_field_slug_index_supports_reverse_lookup(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $group = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + + FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->forFieldSlug('gate') + ->create(); + FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->forFieldSlug('region') + ->create(); + + $gateHits = FormFieldConditionalLogicCondition::query() + ->where('field_slug', 'gate') + ->count(); + $this->assertSame(1, $gateHits); + } +} diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicGroupRelationTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicGroupRelationTest.php new file mode 100644 index 00000000..883d5bfe --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicGroupRelationTest.php @@ -0,0 +1,109 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $root = FormFieldConditionalLogicGroup::factory() + ->forField($field) + ->withOperator(FormFieldConditionalLogicGroupOperator::All) + ->create(); + FormFieldConditionalLogicGroup::factory() + ->nestedUnder($root, 0) + ->withOperator(FormFieldConditionalLogicGroupOperator::Any) + ->create(); + + $groups = $field->fresh()->conditionalLogicGroups; + $this->assertCount(2, $groups); + } + + public function test_root_helper_returns_parentless_group(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $root = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + FormFieldConditionalLogicGroup::factory()->nestedUnder($root, 0)->create(); + FormFieldConditionalLogicGroup::factory()->nestedUnder($root, 1)->create(); + + $found = $field->fresh()->rootConditionalLogicGroup(); + $this->assertNotNull($found); + $this->assertSame($root->id, $found->id); + $this->assertNull($found->parent_group_id); + } + + public function test_group_relations_parent_and_children(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $root = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + $child1 = FormFieldConditionalLogicGroup::factory()->nestedUnder($root, 0)->create(); + $child2 = FormFieldConditionalLogicGroup::factory()->nestedUnder($root, 1)->create(); + + $this->assertSame($root->id, $child1->fresh()->parentGroup->id); + $childIds = $root->fresh()->childGroups->pluck('id')->sort()->values()->all(); + $this->assertSame(collect([$child1->id, $child2->id])->sort()->values()->all(), $childIds); + } + + public function test_group_has_many_conditions(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $group = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + FormFieldConditionalLogicCondition::factory()->inGroup($group, 0)->create(); + FormFieldConditionalLogicCondition::factory()->inGroup($group, 1)->create(); + + $this->assertCount(2, $group->fresh()->conditions); + } + + public function test_operator_enum_casts_roundtrip(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $group = FormFieldConditionalLogicGroup::factory() + ->forField($field) + ->withOperator(FormFieldConditionalLogicGroupOperator::Any) + ->create(); + + $fresh = $group->fresh(); + $this->assertSame(FormFieldConditionalLogicGroupOperator::Any, $fresh->operator); + + $condition = FormFieldConditionalLogicCondition::factory() + ->inGroup($group) + ->withOperator(FormFieldConditionalLogicConditionOperator::GreaterThan, 18) + ->create(); + + $this->assertSame( + FormFieldConditionalLogicConditionOperator::GreaterThan, + $condition->fresh()->comparison_operator, + ); + $this->assertSame(18, $condition->fresh()->value); + } +} diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicScopeTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicScopeTest.php new file mode 100644 index 00000000..7b72df0c --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicScopeTest.php @@ -0,0 +1,103 @@ +seedOrgWithLogic(); + [$orgB, $fieldB] = $this->seedOrgWithLogic(); + + $this->withOrgRoute($orgA); + $fieldIdsA = FormFieldConditionalLogicGroup::query()->pluck('form_field_id')->unique()->values()->all(); + $this->assertSame([$fieldA->id], $fieldIdsA); + + $this->withOrgRoute($orgB); + $fieldIdsB = FormFieldConditionalLogicGroup::query()->pluck('form_field_id')->unique()->values()->all(); + $this->assertSame([$fieldB->id], $fieldIdsB); + } + + public function test_scope_isolates_conditions_per_organisation(): void + { + [$orgA, $fieldA] = $this->seedOrgWithLogic(); + $this->seedOrgWithLogic(); + + $this->withOrgRoute($orgA); + $this->assertSame( + 1, + FormFieldConditionalLogicCondition::query() + ->where('field_slug', 'gate') + ->count(), + ); + } + + public function test_without_global_scope_exposes_cross_org(): void + { + [$orgA] = $this->seedOrgWithLogic(); + $this->seedOrgWithLogic(); + + $this->withOrgRoute($orgA); + + $this->assertSame(1, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame( + 2, + FormFieldConditionalLogicGroup::query() + ->withoutGlobalScope(OrganisationScope::class) + ->count(), + ); + + $this->assertSame(1, FormFieldConditionalLogicCondition::query()->count()); + $this->assertSame( + 2, + FormFieldConditionalLogicCondition::query() + ->withoutGlobalScope(OrganisationScope::class) + ->count(), + ); + } + + /** @return array{0:Organisation,1:FormField} */ + private function seedOrgWithLogic(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $root = FormFieldConditionalLogicGroup::factory()->forField($field)->create(); + FormFieldConditionalLogicCondition::factory() + ->inGroup($root) + ->forFieldSlug('gate') + ->create(); + + return [$org, $field]; + } + + 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/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php index 1afc775a..3697dcfc 100644 --- a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -27,9 +27,10 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase public function test_backfill_translates_tag_categories_and_storage_disk(): void { - // Roll back 5 WS-5b migrations to get the pre-WS-5b state where - // the JSON column still exists on form_fields / form_field_library. - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + // Roll back 2 WS-5c migrations + 5 WS-5b migrations = 7, to get the + // pre-WS-5b state where the JSON column still exists on form_fields + // / form_field_library. + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $fieldId = $this->seedField([ diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 89034ede..bbc60d8e 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -31,14 +31,13 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_forward_migration_backfills_rows_with_field_type_dispatch(): void { - // Roll back: backfill + create-table. Brings us to a state where - // form_fields.validation_rules exists but form_field_validation_rules - // table does not. - // 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(); + // Roll back: 2 WS-5c migrations (create-conditional-logic-conditions, + // create-conditional-logic-groups) + 5 WS-5b migrations + // (drop-cols + configs-backfill + create-configs + + // validation-rules-backfill + create-validation-rules) = 7. + // Brings us to the pre-WS-5b state: validation_rules JSON column + // present, no relational tables for WS-5b. + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -99,7 +98,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' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -123,7 +122,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' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -150,7 +149,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' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -167,7 +166,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' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -186,7 +185,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' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -201,7 +200,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' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); diff --git a/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php b/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php index 434f6ea0..2e911ed8 100644 --- a/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php +++ b/api/tests/Feature/MultiTenancy/ScopeLeakageTest.php @@ -454,31 +454,106 @@ final class ScopeLeakageTest extends TestCase $this->assertSame(1, PersonIdentityMatch::query()->count()); } - public function test_resolver_raises_on_over_deep_chain(): void + public function test_resolver_resolves_four_hop_chain_within_cap(): void { - // Construct a synthetic 5-hop chain by subclassing on the fly: - // A → B → C → D → E (column). The resolver caps at 3 hops and - // must raise TenantScopeResolutionException. - $fiveHopModel = new class extends Model { - protected $table = 'form_value_options'; - public static function tenantScopeStrategy(): array - { - return ['via' => FiveHopLevel2::class, 'fk' => 'form_value_id']; - } - }; + // 4 via-hops + terminal column = legitimate tree inside the cap. + // Build the query and assert no exception; the SQL references every + // intermediate table, proving the walker reached the terminal. + $scope = new OrganisationScope($this->orgA->id); + $builder = (new FourHopLevel1())::query(); - $scope = new OrganisationScope('01HZ01HZ01HZ01HZ01HZ01HZ01'); - $builder = $fiveHopModel::query(); + $scope->apply($builder, new FourHopLevel1()); + + $sql = $builder->toSql(); + $this->assertStringContainsString('form_value_options', $sql); + $this->assertStringContainsString('form_values', $sql); + $this->assertStringContainsString('form_submissions', $sql); + $this->assertStringContainsString('form_schemas', $sql); + $this->assertStringContainsString('organisation_id', $sql); + } + + public function test_resolver_resolves_five_hop_chain_at_cap(): void + { + // 5 via-hops + terminal column — right at the cap ceiling. + $scope = new OrganisationScope($this->orgA->id); + $builder = (new FiveHopLevel1())::query(); + + $scope->apply($builder, new FiveHopLevel1()); + + $this->assertStringContainsString('organisation_id', $builder->toSql()); + } + + public function test_resolver_raises_when_chain_exceeds_cap(): void + { + // 6 via-hops — one past the cap of 5. Must raise. + $scope = new OrganisationScope($this->orgA->id); + $builder = (new SevenHopLevel1())::query(); $this->expectException(TenantScopeResolutionException::class); - $scope->apply($builder, $fiveHopModel); + $scope->apply($builder, new SevenHopLevel1()); } } /** - * Synthetic chain model for the over-deep-chain test — four levels - * above a terminal column, so resolver walks past its cap. + * Synthetic chain models for the scope-cap tests. Tables referenced + * exist in the schema so query compilation succeeds; the intermediate + * relations are never actually executed in these tests (we assert on + * compiled SQL, not on rows). + * + * FourHopLevel1 → Level2 → Level3 → Level4 (terminal column). + * FiveHopLevel1 → Level2 → Level3 → Level4 → Level5 (terminal column). + * SevenHopLevel1 → ... → Level7 (terminal column) — exceeds cap=5. */ +final class FourHopLevel1 extends Model +{ + protected $table = 'form_value_options'; + + public static function tenantScopeStrategy(): array + { + return ['via' => FourHopLevel2::class, 'fk' => 'form_value_id']; + } +} + +final class FourHopLevel2 extends Model +{ + protected $table = 'form_values'; + + public static function tenantScopeStrategy(): array + { + return ['via' => FourHopLevel3::class, 'fk' => 'form_submission_id']; + } +} + +final class FourHopLevel3 extends Model +{ + protected $table = 'form_submissions'; + + public static function tenantScopeStrategy(): array + { + return ['via' => FourHopLevel4::class, 'fk' => 'form_schema_id']; + } +} + +final class FourHopLevel4 extends Model +{ + protected $table = 'form_schemas'; + + public static function tenantScopeStrategy(): array + { + return ['column' => 'organisation_id']; + } +} + +final class FiveHopLevel1 extends Model +{ + protected $table = 'form_value_options'; + + public static function tenantScopeStrategy(): array + { + return ['via' => FiveHopLevel2::class, 'fk' => 'form_value_id']; + } +} + final class FiveHopLevel2 extends Model { protected $table = 'form_values'; @@ -505,16 +580,86 @@ final class FiveHopLevel4 extends Model public static function tenantScopeStrategy(): array { - return ['via' => FiveHopLevel5::class, 'fk' => 'organisation_id']; + return ['via' => FiveHopLevel5::class, 'fk' => 'id']; } } final class FiveHopLevel5 extends Model { - protected $table = 'organisations'; + protected $table = 'form_schemas'; public static function tenantScopeStrategy(): array { - return ['column' => 'id']; + return ['column' => 'organisation_id']; + } +} + +final class SevenHopLevel1 extends Model +{ + protected $table = 'form_value_options'; + + public static function tenantScopeStrategy(): array + { + return ['via' => SevenHopLevel2::class, 'fk' => 'form_value_id']; + } +} + +final class SevenHopLevel2 extends Model +{ + protected $table = 'form_values'; + + public static function tenantScopeStrategy(): array + { + return ['via' => SevenHopLevel3::class, 'fk' => 'form_submission_id']; + } +} + +final class SevenHopLevel3 extends Model +{ + protected $table = 'form_submissions'; + + public static function tenantScopeStrategy(): array + { + return ['via' => SevenHopLevel4::class, 'fk' => 'form_schema_id']; + } +} + +final class SevenHopLevel4 extends Model +{ + protected $table = 'form_schemas'; + + public static function tenantScopeStrategy(): array + { + return ['via' => SevenHopLevel5::class, 'fk' => 'id']; + } +} + +final class SevenHopLevel5 extends Model +{ + protected $table = 'form_schemas'; + + public static function tenantScopeStrategy(): array + { + return ['via' => SevenHopLevel6::class, 'fk' => 'id']; + } +} + +final class SevenHopLevel6 extends Model +{ + protected $table = 'form_schemas'; + + public static function tenantScopeStrategy(): array + { + return ['via' => SevenHopLevel7::class, 'fk' => 'id']; + } +} + +final class SevenHopLevel7 extends Model +{ + protected $table = 'form_schemas'; + + public static function tenantScopeStrategy(): array + { + return ['column' => 'organisation_id']; } }