Files
crewli/api/app/Models/FormBuilder/FormSubmission.php
bert.hausmans ae8e2fdb4e feat(form-builder): denormalize organisation_id and event_id on form_submissions per addendum Q2
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>
2026-04-24 16:56:53 +02:00

124 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models\FormBuilder;
use App\Enums\FormBuilder\FormSubmissionReviewStatus;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* No direct activity-log hooks on this model: lifecycle events fire from the
* FormSubmissionService (arriving in S2) per ARCH §17.1.
*/
final class FormSubmission extends Model
{
use HasFactory;
use HasUlids;
use SoftDeletes;
protected $fillable = [
'form_schema_id',
'organisation_id',
'event_id',
'subject_type',
'subject_id',
'submitted_by_user_id',
'public_submitter_name',
'public_submitter_email',
'public_submitter_ip',
'public_submitter_ip_anonymised_at',
'status',
'review_status',
'reviewed_by_user_id',
'reviewed_at',
'review_notes',
'submitted_at',
'schema_version_at_open',
'schema_version_at_submit',
'schema_snapshot',
'submission_duration_seconds',
'auto_save_count',
'anonymised_at',
'is_test',
'submitted_in_locale',
'opened_at',
'first_interacted_at',
'idempotency_key',
'identity_match_status',
];
/** @var array<string, string> */
protected $casts = [
'status' => FormSubmissionStatus::class,
'review_status' => FormSubmissionReviewStatus::class,
'schema_snapshot' => 'array',
'is_test' => 'bool',
'submitted_at' => 'datetime',
'reviewed_at' => 'datetime',
'anonymised_at' => 'datetime',
'opened_at' => 'datetime',
'first_interacted_at' => 'datetime',
'public_submitter_ip_anonymised_at' => 'datetime',
'schema_version_at_open' => 'int',
'schema_version_at_submit' => 'int',
'submission_duration_seconds' => 'int',
'auto_save_count' => 'int',
];
public function schema(): BelongsTo
{
return $this->belongsTo(FormSchema::class, 'form_schema_id');
}
public function organisation(): BelongsTo
{
return $this->belongsTo(Organisation::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function submittedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_user_id');
}
public function reviewedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
public function values(): HasMany
{
return $this->hasMany(FormValue::class);
}
public function sectionStatuses(): HasMany
{
return $this->hasMany(FormSubmissionSectionStatus::class);
}
public function delegations(): HasMany
{
return $this->hasMany(FormSubmissionDelegation::class);
}
}