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) <noreply@anthropic.com>
666 lines
23 KiB
PHP
666 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\MultiTenancy;
|
|
|
|
use App\Enums\FormBuilder\FormFieldType;
|
|
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
|
|
use App\Enums\IdentityMatchConfidence;
|
|
use App\Enums\IdentityMatchMethod;
|
|
use App\Enums\IdentityMatchStatus;
|
|
use App\Exceptions\TenantScopeResolutionException;
|
|
use App\Models\CrowdType;
|
|
use App\Models\Event;
|
|
use App\Models\FestivalSection;
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\FormBuilder\FormSchemaSection;
|
|
use App\Models\FormBuilder\FormSchemaWebhook;
|
|
use App\Models\FormBuilder\FormSubmission;
|
|
use App\Models\FormBuilder\FormSubmissionDelegation;
|
|
use App\Models\FormBuilder\FormSubmissionSectionStatus;
|
|
use App\Models\FormBuilder\FormValue;
|
|
use App\Models\FormBuilder\FormValueOption;
|
|
use App\Models\FormBuilder\FormWebhookDelivery;
|
|
use App\Models\Organisation;
|
|
use App\Models\Person;
|
|
use App\Models\PersonIdentityMatch;
|
|
use App\Models\PersonSectionPreference;
|
|
use App\Models\Scopes\OrganisationScope;
|
|
use App\Models\Shift;
|
|
use App\Models\ShiftAssignment;
|
|
use App\Models\ShiftWaitlist;
|
|
use App\Models\TimeSlot;
|
|
use App\Models\User;
|
|
use App\Models\VolunteerAvailability;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Routing\Route;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* WS-4 Commit 3 coverage — addendum Q2 FK-chain scope registration.
|
|
*
|
|
* Two fully-populated tenants (orgA, orgB); for each of the 14 newly-
|
|
* registered models, a query scoped to orgA must never surface orgB
|
|
* rows. `withoutGlobalScope` must continue to expose everything for
|
|
* admin-wide lookups. The 3-hop max and deepest real chain
|
|
* (FormValueOption → FormValue → FormSubmission → organisation_id)
|
|
* are exercised explicitly.
|
|
*/
|
|
final class ScopeLeakageTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private Organisation $orgA;
|
|
|
|
private Organisation $orgB;
|
|
|
|
private Event $eventA;
|
|
|
|
private Event $eventB;
|
|
|
|
private FormSchema $schemaA;
|
|
|
|
private FormSchema $schemaB;
|
|
|
|
private FormSubmission $submissionA;
|
|
|
|
private FormSubmission $submissionB;
|
|
|
|
private Person $personA;
|
|
|
|
private Person $personB;
|
|
|
|
private FestivalSection $sectionA;
|
|
|
|
private FestivalSection $sectionB;
|
|
|
|
private Shift $shiftA;
|
|
|
|
private Shift $shiftB;
|
|
|
|
private TimeSlot $timeSlotA;
|
|
|
|
private TimeSlot $timeSlotB;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->orgA = Organisation::factory()->create();
|
|
$this->orgB = Organisation::factory()->create();
|
|
|
|
$this->eventA = Event::factory()->for($this->orgA)->create();
|
|
$this->eventB = Event::factory()->for($this->orgB)->create();
|
|
|
|
$this->sectionA = FestivalSection::factory()->for($this->eventA)->create();
|
|
$this->sectionB = FestivalSection::factory()->for($this->eventB)->create();
|
|
|
|
$this->timeSlotA = TimeSlot::factory()->for($this->eventA)->create();
|
|
$this->timeSlotB = TimeSlot::factory()->for($this->eventB)->create();
|
|
|
|
$this->shiftA = Shift::factory()->for($this->sectionA, 'festivalSection')->for($this->timeSlotA, 'timeSlot')->create();
|
|
$this->shiftB = Shift::factory()->for($this->sectionB, 'festivalSection')->for($this->timeSlotB, 'timeSlot')->create();
|
|
|
|
$crowdA = CrowdType::factory()->for($this->orgA)->create();
|
|
$crowdB = CrowdType::factory()->for($this->orgB)->create();
|
|
|
|
$this->personA = Person::factory()->for($this->eventA)->for($crowdA)->create();
|
|
$this->personB = Person::factory()->for($this->eventB)->for($crowdB)->create();
|
|
|
|
$this->schemaA = FormSchema::factory()->for($this->orgA)->create();
|
|
$this->schemaB = FormSchema::factory()->for($this->orgB)->create();
|
|
|
|
$this->submissionA = FormSubmission::factory()->for($this->schemaA, 'schema')->create();
|
|
$this->submissionB = FormSubmission::factory()->for($this->schemaB, 'schema')->create();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// D-03: Nine form-builder child models
|
|
// ------------------------------------------------------------------
|
|
|
|
public function test_form_schema_section_scope_blocks_cross_org(): void
|
|
{
|
|
FormSchemaSection::factory()->for($this->schemaA, 'schema')->create();
|
|
FormSchemaSection::factory()->for($this->schemaB, 'schema')->create();
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormSchemaSection::query()->count());
|
|
$this->assertSame(2, FormSchemaSection::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_field_scope_blocks_cross_org(): void
|
|
{
|
|
FormField::factory()->for($this->schemaA, 'schema')->create();
|
|
FormField::factory()->for($this->schemaB, 'schema')->create();
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormField::query()->count());
|
|
$this->assertSame(2, FormField::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_submission_scope_blocks_cross_org_via_denormalized_column(): void
|
|
{
|
|
// Commit 2 added organisation_id as a direct column — this model
|
|
// uses the `column` strategy, not `via`. Proves the column
|
|
// strategy still works alongside the FK-chain.
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormSubmission::query()->count());
|
|
$this->assertSame(2, FormSubmission::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_value_scope_blocks_cross_org(): void
|
|
{
|
|
$fieldA = FormField::factory()->for($this->schemaA, 'schema')->create();
|
|
$fieldB = FormField::factory()->for($this->schemaB, 'schema')->create();
|
|
|
|
FormValue::create([
|
|
'form_submission_id' => $this->submissionA->id,
|
|
'form_field_id' => $fieldA->id,
|
|
'value' => ['value' => 'a'],
|
|
'value_anonymised' => false,
|
|
]);
|
|
FormValue::create([
|
|
'form_submission_id' => $this->submissionB->id,
|
|
'form_field_id' => $fieldB->id,
|
|
'value' => ['value' => 'b'],
|
|
'value_anonymised' => false,
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormValue::query()->count());
|
|
$this->assertSame(2, FormValue::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_value_option_scope_handles_three_hop_chain(): void
|
|
{
|
|
// Deepest real chain in the codebase:
|
|
// FormValueOption → FormValue → FormSubmission → organisation_id.
|
|
$fieldA = FormField::factory()->for($this->schemaA, 'schema')->create([
|
|
'field_type' => FormFieldType::MULTISELECT->value,
|
|
]);
|
|
$fieldB = FormField::factory()->for($this->schemaB, 'schema')->create([
|
|
'field_type' => FormFieldType::MULTISELECT->value,
|
|
]);
|
|
|
|
$valueA = FormValue::create([
|
|
'form_submission_id' => $this->submissionA->id,
|
|
'form_field_id' => $fieldA->id,
|
|
'value' => ['x'],
|
|
'value_anonymised' => false,
|
|
]);
|
|
$valueB = FormValue::create([
|
|
'form_submission_id' => $this->submissionB->id,
|
|
'form_field_id' => $fieldB->id,
|
|
'value' => ['y'],
|
|
'value_anonymised' => false,
|
|
]);
|
|
|
|
FormValueOption::create([
|
|
'form_value_id' => $valueA->id,
|
|
'form_field_id' => $fieldA->id,
|
|
'form_submission_id' => $this->submissionA->id,
|
|
'option_value' => 'x',
|
|
]);
|
|
FormValueOption::create([
|
|
'form_value_id' => $valueB->id,
|
|
'form_field_id' => $fieldB->id,
|
|
'form_submission_id' => $this->submissionB->id,
|
|
'option_value' => 'y',
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(
|
|
1,
|
|
FormValueOption::query()->count(),
|
|
'Three-hop chain must filter: FormValueOption → FormValue → FormSubmission → organisation_id'
|
|
);
|
|
$this->assertSame(2, FormValueOption::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_submission_section_status_scope_blocks_cross_org(): void
|
|
{
|
|
$sectionA = FormSchemaSection::factory()->for($this->schemaA, 'schema')->create();
|
|
$sectionB = FormSchemaSection::factory()->for($this->schemaB, 'schema')->create();
|
|
|
|
FormSubmissionSectionStatus::factory()->create([
|
|
'form_submission_id' => $this->submissionA->id,
|
|
'form_schema_section_id' => $sectionA->id,
|
|
]);
|
|
FormSubmissionSectionStatus::factory()->create([
|
|
'form_submission_id' => $this->submissionB->id,
|
|
'form_schema_section_id' => $sectionB->id,
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormSubmissionSectionStatus::query()->count());
|
|
$this->assertSame(2, FormSubmissionSectionStatus::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_submission_delegation_scope_blocks_cross_org(): void
|
|
{
|
|
$userA = User::factory()->create();
|
|
$userB = User::factory()->create();
|
|
|
|
FormSubmissionDelegation::create([
|
|
'form_submission_id' => $this->submissionA->id,
|
|
'delegated_to_user_id' => $userA->id,
|
|
'delegated_by_user_id' => $userA->id,
|
|
'granted_at' => now(),
|
|
]);
|
|
FormSubmissionDelegation::create([
|
|
'form_submission_id' => $this->submissionB->id,
|
|
'delegated_to_user_id' => $userB->id,
|
|
'delegated_by_user_id' => $userB->id,
|
|
'granted_at' => now(),
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormSubmissionDelegation::query()->count());
|
|
$this->assertSame(2, FormSubmissionDelegation::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_schema_webhook_scope_blocks_cross_org(): void
|
|
{
|
|
FormSchemaWebhook::factory()->for($this->schemaA, 'schema')->create();
|
|
FormSchemaWebhook::factory()->for($this->schemaB, 'schema')->create();
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormSchemaWebhook::query()->count());
|
|
$this->assertSame(2, FormSchemaWebhook::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_form_webhook_delivery_scope_blocks_cross_org(): void
|
|
{
|
|
$webhookA = FormSchemaWebhook::factory()->for($this->schemaA, 'schema')->create();
|
|
$webhookB = FormSchemaWebhook::factory()->for($this->schemaB, 'schema')->create();
|
|
|
|
FormWebhookDelivery::create([
|
|
'form_schema_webhook_id' => $webhookA->id,
|
|
'form_submission_id' => $this->submissionA->id,
|
|
'trigger_event' => 'submission.submitted',
|
|
'status' => FormWebhookDeliveryStatus::PENDING->value,
|
|
'attempts' => 0,
|
|
'payload_snapshot' => ['x' => 1],
|
|
]);
|
|
FormWebhookDelivery::create([
|
|
'form_schema_webhook_id' => $webhookB->id,
|
|
'form_submission_id' => $this->submissionB->id,
|
|
'trigger_event' => 'submission.submitted',
|
|
'status' => FormWebhookDeliveryStatus::PENDING->value,
|
|
'attempts' => 0,
|
|
'payload_snapshot' => ['y' => 1],
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, FormWebhookDelivery::query()->count());
|
|
$this->assertSame(2, FormWebhookDelivery::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// D-04 event-data subset: five models
|
|
// ------------------------------------------------------------------
|
|
|
|
public function test_shift_assignment_scope_blocks_cross_org(): void
|
|
{
|
|
ShiftAssignment::factory()
|
|
->for($this->shiftA)
|
|
->for($this->personA)
|
|
->for($this->timeSlotA)
|
|
->create();
|
|
ShiftAssignment::factory()
|
|
->for($this->shiftB)
|
|
->for($this->personB)
|
|
->for($this->timeSlotB)
|
|
->create();
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, ShiftAssignment::query()->count());
|
|
$this->assertSame(2, ShiftAssignment::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_shift_waitlist_scope_blocks_cross_org(): void
|
|
{
|
|
ShiftWaitlist::create([
|
|
'shift_id' => $this->shiftA->id,
|
|
'person_id' => $this->personA->id,
|
|
'position' => 1,
|
|
'added_at' => now(),
|
|
]);
|
|
ShiftWaitlist::create([
|
|
'shift_id' => $this->shiftB->id,
|
|
'person_id' => $this->personB->id,
|
|
'position' => 1,
|
|
'added_at' => now(),
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, ShiftWaitlist::query()->count());
|
|
$this->assertSame(2, ShiftWaitlist::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_volunteer_availability_scope_blocks_cross_org(): void
|
|
{
|
|
VolunteerAvailability::create([
|
|
'person_id' => $this->personA->id,
|
|
'time_slot_id' => $this->timeSlotA->id,
|
|
'preference_level' => 3,
|
|
'submitted_at' => now(),
|
|
]);
|
|
VolunteerAvailability::create([
|
|
'person_id' => $this->personB->id,
|
|
'time_slot_id' => $this->timeSlotB->id,
|
|
'preference_level' => 3,
|
|
'submitted_at' => now(),
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, VolunteerAvailability::query()->count());
|
|
$this->assertSame(2, VolunteerAvailability::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_person_section_preference_scope_blocks_cross_org(): void
|
|
{
|
|
PersonSectionPreference::create([
|
|
'person_id' => $this->personA->id,
|
|
'festival_section_id' => $this->sectionA->id,
|
|
'priority' => 1,
|
|
]);
|
|
PersonSectionPreference::create([
|
|
'person_id' => $this->personB->id,
|
|
'festival_section_id' => $this->sectionB->id,
|
|
'priority' => 1,
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, PersonSectionPreference::query()->count());
|
|
$this->assertSame(2, PersonSectionPreference::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
public function test_person_identity_match_scope_blocks_cross_org(): void
|
|
{
|
|
$matchedUserA = User::factory()->create();
|
|
$matchedUserB = User::factory()->create();
|
|
|
|
PersonIdentityMatch::create([
|
|
'person_id' => $this->personA->id,
|
|
'matched_user_id' => $matchedUserA->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL,
|
|
'confidence' => IdentityMatchConfidence::HIGH,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
'match_details' => [],
|
|
]);
|
|
PersonIdentityMatch::create([
|
|
'person_id' => $this->personB->id,
|
|
'matched_user_id' => $matchedUserB->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL,
|
|
'confidence' => IdentityMatchConfidence::HIGH,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
'match_details' => [],
|
|
]);
|
|
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertSame(1, PersonIdentityMatch::query()->count());
|
|
$this->assertSame(2, PersonIdentityMatch::withoutGlobalScope(OrganisationScope::class)->count());
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Resolver mechanics: legacy-bridge compatibility + hop limit
|
|
// ------------------------------------------------------------------
|
|
|
|
public function test_resolver_accepts_legacy_organisation_scope_column_bridge(): void
|
|
{
|
|
// PersonIdentityMatch → Person. Person uses the legacy
|
|
// $organisationScopeColumn = 'event_id' bridge (not
|
|
// tenantScopeStrategy()). The recursive resolver must treat it
|
|
// as a terminal column strategy.
|
|
$this->withOrgRoute($this->orgA);
|
|
$this->assertInstanceOf(
|
|
PersonIdentityMatch::class,
|
|
PersonIdentityMatch::query()->getModel(),
|
|
'Scope must boot on models whose parent uses the legacy property'
|
|
);
|
|
|
|
// Actually prove the chain works end-to-end: one row per org,
|
|
// scoped query returns only the owned row.
|
|
$matchedUserA = User::factory()->create();
|
|
$matchedUserB = User::factory()->create();
|
|
|
|
PersonIdentityMatch::create([
|
|
'person_id' => $this->personA->id,
|
|
'matched_user_id' => $matchedUserA->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL,
|
|
'confidence' => IdentityMatchConfidence::HIGH,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
'match_details' => [],
|
|
]);
|
|
PersonIdentityMatch::create([
|
|
'person_id' => $this->personB->id,
|
|
'matched_user_id' => $matchedUserB->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL,
|
|
'confidence' => IdentityMatchConfidence::HIGH,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
'match_details' => [],
|
|
]);
|
|
$this->assertSame(1, PersonIdentityMatch::query()->count());
|
|
}
|
|
|
|
public function test_resolver_resolves_four_hop_chain_within_cap(): void
|
|
{
|
|
// 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->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, new SevenHopLevel1());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
|
|
public static function tenantScopeStrategy(): array
|
|
{
|
|
return ['via' => FiveHopLevel3::class, 'fk' => 'form_submission_id'];
|
|
}
|
|
}
|
|
|
|
final class FiveHopLevel3 extends Model
|
|
{
|
|
protected $table = 'form_submissions';
|
|
|
|
public static function tenantScopeStrategy(): array
|
|
{
|
|
return ['via' => FiveHopLevel4::class, 'fk' => 'form_schema_id'];
|
|
}
|
|
}
|
|
|
|
final class FiveHopLevel4 extends Model
|
|
{
|
|
protected $table = 'form_schemas';
|
|
|
|
public static function tenantScopeStrategy(): array
|
|
{
|
|
return ['via' => FiveHopLevel5::class, 'fk' => 'id'];
|
|
}
|
|
}
|
|
|
|
final class FiveHopLevel5 extends Model
|
|
{
|
|
protected $table = 'form_schemas';
|
|
|
|
public static function tenantScopeStrategy(): array
|
|
{
|
|
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'];
|
|
}
|
|
}
|