diff --git a/api/app/Models/Scopes/FormFieldBindingScope.php b/api/app/Models/Scopes/FormFieldBindingScope.php index 3bfc90de..35413019 100644 --- a/api/app/Models/Scopes/FormFieldBindingScope.php +++ b/api/app/Models/Scopes/FormFieldBindingScope.php @@ -4,103 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Multi-tenant isolation for `form_field_bindings`. The table has a - * polymorphic owner that points at either `form_field` or - * `form_field_library`; `OrganisationScope` (Q2 FK-chain resolver) can't - * walk a morph parent, so this scope does the equivalent UNION walk: + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * owner_id ∈ ( - * SELECT id FROM form_fields - * WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?) - * UNION - * SELECT id FROM form_field_library - * WHERE organisation_id = ? - * ) + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldBindingScope::class)` call sites in + * cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * Organisation context resolution mirrors `OrganisationScope` — explicit - * override via constructor, then the `organisation` / `event` route - * parameter fallbacks. CLI, queues, and unauthenticated flows skip the - * scope (consistent with OrganisationScope). - * - * Escape hatch: callers that need cross-tenant reads use - * `FormFieldBinding::withoutGlobalScope(FormFieldBindingScope::class)`. + * History: WS-5a; ARCH-FORM-BUILDER §6.7 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldBindingScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->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; - } -} +final class FormFieldBindingScope extends FormFieldChildTableMorphScope {} diff --git a/api/app/Models/Scopes/FormFieldChildTableMorphScope.php b/api/app/Models/Scopes/FormFieldChildTableMorphScope.php new file mode 100644 index 00000000..38d490d6 --- /dev/null +++ b/api/app/Models/Scopes/FormFieldChildTableMorphScope.php @@ -0,0 +1,125 @@ +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", self::OWNER_TYPE_FIELD) + ->whereIn("$table.owner_id", $fieldIds); + })->orWhere(function (Builder $q) use ($table, $libraryIds): void { + $q->where("$table.owner_type", self::OWNER_TYPE_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 Organisation) { + return $org->id; + } + + if (is_string($org) && $org !== '') { + return $org; + } + + $event = $route->parameter('event'); + if ($event instanceof Event) { + return $event->organisation_id; + } + + return null; + } +} diff --git a/api/app/Models/Scopes/FormFieldConfigScope.php b/api/app/Models/Scopes/FormFieldConfigScope.php index ab07a783..d868b10d 100644 --- a/api/app/Models/Scopes/FormFieldConfigScope.php +++ b/api/app/Models/Scopes/FormFieldConfigScope.php @@ -4,99 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Third sibling in the form-field-child-table scope family, after - * `FormFieldBindingScope` (WS-5a) and `FormFieldValidationRuleScope` - * (WS-5b commit 1). Identical UNION-over-two-owner-chains shape: + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * owner_id ∈ ( - * SELECT id FROM form_fields - * WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?) - * UNION - * SELECT id FROM form_field_library - * WHERE organisation_id = ? - * ) + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldConfigScope::class)` call sites in + * cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * Base-class extraction between the three siblings is deliberately - * deferred to WS-5d (where `form_field_options` lands and the fourth - * concrete implementation may clarify what truly varies). Premature - * abstraction from three is still premature. + * History: WS-5b commit 5; ARCH-FORM-BUILDER §17.5.3 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldConfigScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->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; - } -} +final class FormFieldConfigScope extends FormFieldChildTableMorphScope {} diff --git a/api/app/Models/Scopes/FormFieldOptionScope.php b/api/app/Models/Scopes/FormFieldOptionScope.php index c7be15d4..9f397d4c 100644 --- a/api/app/Models/Scopes/FormFieldOptionScope.php +++ b/api/app/Models/Scopes/FormFieldOptionScope.php @@ -4,104 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Fourth and final sibling in the form-field-child-table scope family, - * after `FormFieldBindingScope` (WS-5a), - * `FormFieldValidationRuleScope` and `FormFieldConfigScope` (WS-5b). + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * Identical UNION-over-two-owner-chains shape: + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldOptionScope::class)` call sites in + * cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * owner_id ∈ ( - * SELECT id FROM form_fields - * WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?) - * UNION - * SELECT id FROM form_field_library - * WHERE organisation_id = ? - * ) - * - * Now that all four concrete implementations exist, base-class - * extraction across the family is the logical follow-up — deferred to - * a separate work package per addendum §17.4.3 / §17.5.3 / §17.6.3. - * - * Escape hatch: `FormFieldOption::withoutGlobalScope(FormFieldOptionScope::class)` - * for cascade observers, backfill migrations, and platform super_admin - * paths. + * History: WS-5d; ARCH-FORM-BUILDER §17.6.3 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldOptionScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->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; - } -} +final class FormFieldOptionScope extends FormFieldChildTableMorphScope {} diff --git a/api/app/Models/Scopes/FormFieldValidationRuleScope.php b/api/app/Models/Scopes/FormFieldValidationRuleScope.php index 61a66201..9c304efa 100644 --- a/api/app/Models/Scopes/FormFieldValidationRuleScope.php +++ b/api/app/Models/Scopes/FormFieldValidationRuleScope.php @@ -4,99 +4,17 @@ declare(strict_types=1); namespace App\Models\Scopes; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Scope; - /** - * Multi-tenant isolation for `form_field_validation_rules`. Sibling to - * `FormFieldBindingScope` — the two share the same UNION shape over the - * polymorphic owner's two possible parents (`form_field → form_schema → - * organisation_id` ∪ `form_field_library → organisation_id`). + * Marker subclass — see FormFieldChildTableMorphScope for the full + * UNION-over-two-owner-chains scope logic. * - * Duplicate code with `FormFieldBindingScope` is acknowledged; base-class - * extraction is deferred to WS-5d per the architect addendum Q3 decision: - * premature abstraction from two is still premature, and WS-5d adds a - * third sibling that will make what truly varies visible. + * The class identity is preserved so that existing + * `withoutGlobalScope(FormFieldValidationRuleScope::class)` call sites + * in cascade observers, backfill migrations, and platform super_admin + * paths continue to work unchanged. The behaviour is identical + * across all four siblings. * - * Organisation context resolution mirrors `OrganisationScope` — explicit - * override via constructor, then `organisation` / `event` route parameter - * fallbacks. CLI, queues, and unauthenticated flows skip the scope. - * - * Escape hatch: - * `FormFieldValidationRule::withoutGlobalScope(FormFieldValidationRuleScope::class)`. + * History: WS-5b; ARCH-FORM-BUILDER §17.4.3 and + * ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 Uitvoering sections. */ -final class FormFieldValidationRuleScope implements Scope -{ - public function __construct( - private readonly ?string $organisationId = null, - ) {} - - public function apply(Builder $builder, Model $model): void - { - $orgId = $this->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; - } -} +final class FormFieldValidationRuleScope extends FormFieldChildTableMorphScope {}