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>
S2c D5 completion: schema_version_at_open column + drift semantics.
- Migration 2026_04_22_100002 adds unsignedInteger schema_version_at_open.
Recorded by FormSubmissionService::createDraft at the moment the
portal first renders the form.
- PublicFormSubmissionResource.schema_drift now compares
schema_version_at_open vs schema_version_at_submit (or
schema.version for active drafts) so organiser edits during an
open draft surface as drift on subsequent PUT/submit responses.
- PublicFormSubmissionController::update routes through
FormSubmissionService::saveDraft so auto_save_count increments
and the FormSubmissionDraftUpdated event fires per PUT.
- bootstrap/app.php: FormRequest ValidationException on
/api/v1/public/forms/* is now re-wrapped into the D6 envelope with
code=VALIDATION_FAILED, so public endpoints emit one consistent
error shape regardless of layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D9. Implements ARCH §31.1 — identity matching triggered on
FormSubmissionSubmitted for event_registration schemas.
- Migration 2026_04_22_100000: add form_submissions.identity_match_status
(nullable string(20), pending|matched|none) + index
(form_schema_id, identity_match_status).
- Migration 2026_04_22_100001: replace the composite index on
(form_schema_id, idempotency_key) with a UNIQUE constraint so the DB
itself is the race-safe backstop behind the application-level
idempotency replay.
- Listener TriggerPersonIdentityMatchOnFormSubmit: runs only when
form_schema.purpose === event_registration. For person-subject
submissions it calls PersonIdentityService::detectMatches and writes
matched/pending/none; for public (subject=null) it records 'pending'
so the portal can message the submitter that matching will complete
when the organiser attaches a person. Failures log at error level
and never rethrow — sibling listeners on the same event (§31.10
TAG_PICKER sync) still run.
- AppServiceProvider wires the listener alongside
SyncTagPickerSelectionsOnSubmit.
- FormSubmission.$fillable gains identity_match_status.
Rationale for a dedicated column (over JSON on submission.metadata):
the matrix is a hard-typed 3-state enum that the public API surfaces
directly, and we want to index it to show organiser dashboards "how
many submissions are pending identity-confirmation".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of S1.
Models (app/Models/FormBuilder/): FormSchema, FormSchemaSection, FormField,
FormSubmission, FormValue, FormValueOption, FormTemplate, FormFieldLibrary,
FormSchemaWebhook, FormWebhookDelivery, FormSubmissionSectionStatus,
FormSubmissionDelegation. Plus UserProfile at app/Models/ (user-universal).
OrganisationScope applied on: FormSchema, FormTemplate, FormFieldLibrary.
FormSchemaWebhook documents inherited-scope discipline (OrganisationScope's
strategies — organisation_id/event_id/festival_section_id — don't cover
form_schema_id; direct queries would leak across orgs, so must go via
$schema->webhooks()).
User::profile()/getOrCreateProfile(), Event::formSchemas() (morphMany),
Person::formSubmissions() (morphMany).
Morph map enforced in AppServiceProvider with 28 keys covering every model
that appears as activitylog subject/causer. Also updated
OrganisationDashboardService (and its test) to query activitylog via
getMorphClass() instead of FQCN.
Activity log strategy: nuanced explicit calls (logSchemaChange on FormSchema,
logFieldChange on FormField) — no LogsActivity trait. Suppression for bulk
fixtures via App\Support\ActivityLog::suppressed(fn() => ...) which flips
config('activitylog.enabled') around a callback. Both our explicit calls
and spatie's trait on Organisation respect the flag via ActivityLogger::log().
FormValueObserver (app/Observers/FormBuilder/) populates value_indexed/
value_number/value_date/value_bool on save per field.value_storage_hint,
rebuilds form_value_options pivot on multi-value filterable fields, cleans
up on delete. Memoised field cache avoids N+1. Registered in AppServiceProvider.
9 lightweight event classes (app/Events/FormBuilder/) as SerializesModels
containers — submission lifecycle signatures lock in for S2 services, no
listeners yet.
Factories for all models with Dutch fake data (fake('nl_NL')). FormSchema
factory uses defaultSubmissionMode(); FormField factory uses
recommendedValueStorageHint().
Tests: 9 new observer tests (all pass); full suite 910/910 (up from 901).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>