Adds direct tenant + event columns to form_submissions so rapportage-hot
aggregate queries (dashboards, CSV-exports, counts over thousands of rows
per org or per event) skip the form_schemas join. This is the single
denormalization exception per addendum Q2; every other form-builder child
table continues to resolve tenancy via FK-chain through its parent
(implemented in Commit 3).
Schema:
- form_submissions.organisation_id ULID FK → organisations, cascade delete, NOT NULL
- form_submissions.event_id ULID FK → events, null on delete, nullable
- Indexes: (organisation_id, status), (event_id, status)
Observer: App\Observers\FormBuilder\FormSubmissionObserver::creating
resolves both columns when the caller has not set them.
- organisation_id <- form_schema.organisation_id (always present —
form_schemas carries OrganisationScope's column directly)
- event_id <- schema.owner_id when owner_type === 'event'; else the
active route's {event} parameter; else null (user_profile /
signature_contract purposes)
The observer docblock spells out both resolution paths and is covered
by the observer test below.
Model: FormSubmission gains organisation_id + event_id in $fillable, a
belongsTo organisation() and belongsTo event() relation.
Factory: FormSubmissionFactory gains forOrganisation($org) and
forEvent($event) states for tests that need to override the observer's
automatic resolution (e.g. cross-org leakage scenarios in Commit 3).
Normal factory usage does not need the states — the observer populates
both fields on save.
Docs:
- SCHEMA.md §3.5.12 form_submissions table — organisation_id and event_id
inserted between form_schema_id and subject_type; indexes added;
addendum Q2 rationale paragraph at the bottom explaining why this is
the only denormalized form-builder child.
- ARCH-FORM-BUILDER.md §4.3 — mirror changes + rationale inline on the
columns and in the indexes list.
Tests: tests/Feature/FormBuilder/FormSubmissionObserverTest.php — 7 tests
covering organisation resolution from schema, event resolution from
event-owned schema, null event_id for non-event-owned schemas without
route context, route-based event resolution, organisation_id populated
on every create path (factory / new() / Model::create), index presence,
and belongsTo relations. 13 new assertions. Full suite: 984 passed
(2675 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82 lines
2.8 KiB
PHP
82 lines
2.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Observers\FormBuilder;
|
|
|
|
use App\Models\Event;
|
|
use App\Models\FormBuilder\FormSubmission;
|
|
use App\Models\Scopes\OrganisationScope;
|
|
|
|
/**
|
|
* Populates the denormalized organisation_id and event_id columns on
|
|
* form_submissions per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q2.
|
|
*
|
|
* organisation_id resolves from the parent schema (always present —
|
|
* form_schemas carries the tenant column directly). event_id resolves
|
|
* to the schema owner when owner_type === 'event'; otherwise it falls
|
|
* back to the active route's {event} parameter (for submissions created
|
|
* inside an events/{event}/... route, e.g. the portal flow). Purposes
|
|
* like user_profile or signature_contract that have neither an
|
|
* event-owned schema nor an event-scoped route produce event_id = null,
|
|
* which is valid per migration 2026_04_24_200000.
|
|
*/
|
|
final class FormSubmissionObserver
|
|
{
|
|
public function creating(FormSubmission $submission): void
|
|
{
|
|
if ($submission->organisation_id === null) {
|
|
$submission->organisation_id = $this->resolveOrganisationId($submission);
|
|
}
|
|
|
|
if ($submission->event_id === null) {
|
|
$submission->event_id = $this->resolveEventId($submission);
|
|
}
|
|
}
|
|
|
|
private function resolveOrganisationId(FormSubmission $submission): ?string
|
|
{
|
|
if ($submission->form_schema_id === null) {
|
|
return null;
|
|
}
|
|
|
|
$schema = $submission->relationLoaded('schema')
|
|
? $submission->getRelation('schema')
|
|
: \App\Models\FormBuilder\FormSchema::query()
|
|
->withoutGlobalScope(OrganisationScope::class)
|
|
->find($submission->form_schema_id);
|
|
|
|
return $schema?->organisation_id;
|
|
}
|
|
|
|
private function resolveEventId(FormSubmission $submission): ?string
|
|
{
|
|
$schema = $submission->relationLoaded('schema')
|
|
? $submission->getRelation('schema')
|
|
: \App\Models\FormBuilder\FormSchema::query()
|
|
->withoutGlobalScope(OrganisationScope::class)
|
|
->find($submission->form_schema_id);
|
|
|
|
if ($schema !== null && $schema->owner_type === 'event' && $schema->owner_id !== null) {
|
|
return (string) $schema->owner_id;
|
|
}
|
|
|
|
// Fall back to the active route — portal + organizer flows that
|
|
// scope submissions by the current event via /events/{event}/...
|
|
$route = request()?->route();
|
|
if ($route === null) {
|
|
return null;
|
|
}
|
|
|
|
$eventParam = $route->parameter('event');
|
|
if ($eventParam instanceof Event) {
|
|
return (string) $eventParam->id;
|
|
}
|
|
if (is_string($eventParam) && $eventParam !== '') {
|
|
return $eventParam;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|