diff --git a/api/app/Models/FormBuilder/FormSubmission.php b/api/app/Models/FormBuilder/FormSubmission.php index c2641ab5..e3ad9325 100644 --- a/api/app/Models/FormBuilder/FormSubmission.php +++ b/api/app/Models/FormBuilder/FormSubmission.php @@ -6,6 +6,8 @@ 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; @@ -27,6 +29,8 @@ final class FormSubmission extends Model protected $fillable = [ 'form_schema_id', + 'organisation_id', + 'event_id', 'subject_type', 'subject_id', 'submitted_by_user_id', @@ -77,6 +81,16 @@ final class FormSubmission extends Model 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(); diff --git a/api/app/Observers/FormBuilder/FormSubmissionObserver.php b/api/app/Observers/FormBuilder/FormSubmissionObserver.php new file mode 100644 index 00000000..73795580 --- /dev/null +++ b/api/app/Observers/FormBuilder/FormSubmissionObserver.php @@ -0,0 +1,81 @@ +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; + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 3189c6d0..192c3f50 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -48,6 +48,7 @@ use App\Models\VolunteerAvailability; use App\Events\FormBuilder\FormSubmissionSubmitted; use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit; use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit; +use App\Observers\FormBuilder\FormSubmissionObserver; use App\Observers\FormBuilder\FormValueObserver; use App\Observers\PersonObserver; use App\Observers\UserObserver; @@ -91,6 +92,7 @@ class AppServiceProvider extends ServiceProvider Person::observe(PersonObserver::class); User::observe(UserObserver::class); FormValue::observe(FormValueObserver::class); + \App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class); // ARCH §31.10 — FORM-02 TAG_PICKER sync listener. \Illuminate\Support\Facades\Event::listen( diff --git a/api/database/factories/FormBuilder/FormSubmissionFactory.php b/api/database/factories/FormBuilder/FormSubmissionFactory.php index 911a0478..4bc4b264 100644 --- a/api/database/factories/FormBuilder/FormSubmissionFactory.php +++ b/api/database/factories/FormBuilder/FormSubmissionFactory.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace Database\Factories\FormBuilder; use App\Enums\FormBuilder\FormSubmissionStatus; +use App\Models\Event; use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSubmission; +use App\Models\Organisation; use Illuminate\Database\Eloquent\Factories\Factory; /** @extends Factory */ @@ -40,4 +42,26 @@ final class FormSubmissionFactory extends Factory { return $this->state(fn () => ['is_test' => true]); } + + /** + * Force a specific tenant. The observer would otherwise resolve it + * from the schema parent; this state lets tests override for edge + * cases (e.g. cross-org leakage scenarios in Commit 3). + */ + public function forOrganisation(Organisation $organisation): static + { + return $this->state(fn () => ['organisation_id' => $organisation->id]); + } + + /** + * Force a specific event + its organisation. Addendum Q2 contract: + * any submission tied to an event also has that event's org. + */ + public function forEvent(Event $event): static + { + return $this->state(fn () => [ + 'event_id' => $event->id, + 'organisation_id' => $event->organisation_id, + ]); + } } diff --git a/api/database/migrations/2026_04_24_200000_add_denormalized_context_to_form_submissions.php b/api/database/migrations/2026_04_24_200000_add_denormalized_context_to_form_submissions.php new file mode 100644 index 00000000..860443b5 --- /dev/null +++ b/api/database/migrations/2026_04_24_200000_add_denormalized_context_to_form_submissions.php @@ -0,0 +1,49 @@ +foreignUlid('organisation_id') + ->after('form_schema_id') + ->constrained('organisations') + ->cascadeOnDelete(); + + // event_id is nullable — purposes like user_profile have no + // event. The observer resolves it from the schema owner + // (owner_type === 'event') or from the active route. + $table->foreignUlid('event_id') + ->nullable() + ->after('organisation_id') + ->constrained('events') + ->nullOnDelete(); + + $table->index(['organisation_id', 'status'], 'fs_org_status_idx'); + $table->index(['event_id', 'status'], 'fs_event_status_idx'); + }); + } + + public function down(): void + { + Schema::table('form_submissions', function (Blueprint $table): void { + $table->dropIndex('fs_org_status_idx'); + $table->dropIndex('fs_event_status_idx'); + $table->dropConstrainedForeignId('organisation_id'); + $table->dropConstrainedForeignId('event_id'); + }); + } +}; diff --git a/api/tests/Feature/FormBuilder/FormSubmissionObserverTest.php b/api/tests/Feature/FormBuilder/FormSubmissionObserverTest.php new file mode 100644 index 00000000..38cc57a3 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSubmissionObserverTest.php @@ -0,0 +1,166 @@ +create(); + $schema = FormSchema::factory()->for($organisation)->create(); + + $submission = FormSubmission::factory()->for($schema, 'schema')->create(); + + $this->assertSame( + $organisation->id, + $submission->organisation_id, + 'Observer must denormalize organisation_id from schema parent' + ); + } + + public function test_event_id_resolves_from_event_owned_schema(): void + { + $organisation = Organisation::factory()->create(); + $event = Event::factory()->for($organisation)->create(); + $schema = FormSchema::factory()->for($organisation)->create([ + 'owner_type' => 'event', + 'owner_id' => $event->id, + ]); + + $submission = FormSubmission::factory()->for($schema, 'schema')->create(); + + $this->assertSame( + $event->id, + $submission->event_id, + 'Observer must resolve event_id from schema.owner_id when owner_type = event' + ); + } + + public function test_event_id_is_null_when_schema_has_no_event_owner_and_no_route(): void + { + $organisation = Organisation::factory()->create(); + $schema = FormSchema::factory()->for($organisation)->create([ + 'owner_type' => 'organisation', + 'owner_id' => $organisation->id, + ]); + + $submission = FormSubmission::factory()->for($schema, 'schema')->create(); + + $this->assertNull( + $submission->event_id, + 'Non-event-owned schema outside an event route yields event_id = null' + ); + $this->assertSame($organisation->id, $submission->organisation_id); + } + + public function test_event_id_resolves_from_active_route_when_schema_has_no_event_owner(): void + { + $organisation = Organisation::factory()->create(); + $event = Event::factory()->for($organisation)->create(); + $schema = FormSchema::factory()->for($organisation)->create([ + 'owner_type' => 'organisation', + 'owner_id' => $organisation->id, + ]); + + // Simulate a request bound to /events/{event}/... by firing the + // observer inside a controller invocation with the route + // parameter populated. + Route::get( + '/_test/events/{event}/submissions', + function (Event $event) use ($schema): array { + $submission = new FormSubmission(); + $submission->form_schema_id = $schema->id; + $submission->status = 'draft'; + $submission->is_test = false; + $submission->save(); + + return ['id' => $submission->id, 'event_id' => $submission->event_id]; + } + ); + + $response = $this->getJson("/_test/events/{$event->id}/submissions"); + $response->assertOk(); + + $this->assertSame( + $event->id, + $response->json('event_id'), + 'Route {event} parameter resolves event_id when schema has no event owner' + ); + } + + public function test_organisation_id_is_populated_on_every_create_path(): void + { + $organisation = Organisation::factory()->create(); + $schema = FormSchema::factory()->for($organisation)->create(); + + // Direct factory + $s1 = FormSubmission::factory()->for($schema, 'schema')->create(); + // Direct new() + save() + $s2 = new FormSubmission(); + $s2->form_schema_id = $schema->id; + $s2->status = 'draft'; + $s2->is_test = false; + $s2->save(); + // Model::create() + $s3 = FormSubmission::create([ + 'form_schema_id' => $schema->id, + 'status' => 'draft', + 'is_test' => false, + ]); + + foreach ([$s1, $s2, $s3] as $submission) { + $this->assertSame( + $organisation->id, + $submission->organisation_id, + 'organisation_id must never be NULL — resolved on every create path' + ); + } + } + + public function test_denormalized_indexes_exist(): void + { + // Indexes named in the migration — addendum Q2 rapportage-hot. + $indexNames = collect(DB::select( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='form_submissions'" + ))->pluck('name')->toArray(); + + $this->assertContains('fs_org_status_idx', $indexNames); + $this->assertContains('fs_event_status_idx', $indexNames); + } + + public function test_organisation_and_event_belongs_to_relationships_resolve(): void + { + $organisation = Organisation::factory()->create(); + $event = Event::factory()->for($organisation)->create(); + $schema = FormSchema::factory()->for($organisation)->create([ + 'owner_type' => 'event', + 'owner_id' => $event->id, + ]); + + $submission = FormSubmission::factory()->for($schema, 'schema')->create(); + + $this->assertTrue($submission->organisation->is($organisation)); + $this->assertTrue($submission->event->is($event)); + } +} diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index f319e2b0..c54d71bf 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -666,6 +666,8 @@ submissions of the same schema. Enforcement: |---|---|---| | `id` | ULID | PK | | `form_schema_id` | ULID FK | → form_schemas | +| `organisation_id` | ULID FK | → organisations, cascade delete. **Denormalized per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 Q2** — the single rapportage-hot exception. Populated by `FormSubmissionObserver::creating` from the schema parent. | +| `event_id` | ULID FK nullable | → events, null on delete. Denormalized per addendum Q2. Observer resolves from `form_schemas.owner_id` when `owner_type = event`; else from the active route's `{event}` parameter. Null for purposes without an event context (`user_profile`, `signature_contract`). | | `subject_type` | string nullable | polymorph | | `subject_id` | ULID nullable | polymorph target | | `submitted_by_user_id` | ULID FK nullable | Authenticated submitter | @@ -694,7 +696,7 @@ submissions of the same schema. Enforcement: **Relations:** belongsTo form_schema, hasMany form_values, hasMany form_submission_section_statuses, hasMany form_submission_delegations, belongsTo submittedBy / reviewedBy (User), morphsTo subject -**Indexes:** `(form_schema_id, status)`, `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)` partial where not null, `FULLTEXT(search_index)` (MySQL) +**Indexes:** `(form_schema_id, status)`, `(organisation_id, status)` (addendum Q2 — dashboards + CSV exports aggregate by tenant + status), `(event_id, status)` (addendum Q2 — event-scoped reporting), `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)` partial where not null, `FULLTEXT(search_index)` (MySQL) **Unique:** `(form_schema_id, idempotency_key)` partial where not null diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 9f4b0ebc..a1990e12 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -2033,6 +2033,8 @@ that aggregates the user's submitted, non-test `form_submissions`. | ------------------------------------- | ------------------ | ---------------------------------------------------------------------------- | | `id` | ULID | PK | | `form_schema_id` | ULID FK | → form_schemas, cascade delete | +| `organisation_id` | ULID FK | → organisations, cascade delete. Denormalized per addendum Q2 — this is the **only** form-builder child that carries a direct tenant column; every other child resolves tenant via FK-chain through its parent. Populated by `FormSubmissionObserver` from the schema parent. | +| `event_id` | ULID FK nullable | → events, null on delete. Denormalized per addendum Q2. Observer resolves from `form_schemas.owner_id` when `owner_type = event`; otherwise from the active route's `{event}` parameter. Null for purposes like `user_profile` / `signature_contract`. | | `subject_type` | string(50) null | polymorph | | `subject_id` | ULID nullable | polymorph target | | `submitted_by_user_id` | ULID FK nullable | → users, null on delete | @@ -2062,11 +2064,22 @@ that aggregates the user's submitted, non-test `form_submissions`. | `created_at`, `updated_at` | timestamps | | | `deleted_at` | timestamp nullable | Soft delete | -**Relations:** `belongsTo` schema, submittedBy / reviewedBy (User); `morphsTo` subject; `hasMany` values, section statuses, delegations -**Indexes:** `(form_schema_id, status)`, `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)`, **UNIQUE** `(form_schema_id, idempotency_key)` (v2.1; replaced the non-unique composite index from v2.0), `(form_schema_id, identity_match_status)` (v2.1), `FULLTEXT(search_index)` (MySQL/InnoDB — best-effort, skipped gracefully on SQLite) +**Relations:** `belongsTo` schema, organisation, event, submittedBy / reviewedBy (User); `morphsTo` subject; `hasMany` values, section statuses, delegations +**Indexes:** `(form_schema_id, status)`, `(organisation_id, status)` (v2.2 — addendum Q2), `(event_id, status)` (v2.2 — addendum Q2), `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)`, **UNIQUE** `(form_schema_id, idempotency_key)` (v2.1; replaced the non-unique composite index from v2.0), `(form_schema_id, identity_match_status)` (v2.1), `FULLTEXT(search_index)` (MySQL/InnoDB — best-effort, skipped gracefully on SQLite) **Events fired:** `FormSubmissionCreated`, `FormSubmissionDraftUpdated`, `FormSubmissionSubmitted`, `FormSubmissionReviewed`, `FormSubmissionSectionSubmitted`, `FormSubmissionSectionReviewed`, `FormSubmissionAnonymised`, `FormSubmissionArchived`, `FormSubmissionDeleted` **Soft delete:** yes +> **Denormalization rationale (addendum Q2):** `form_submissions` is the +> single rapportage-hot table that earns a denormalized `organisation_id` +> (and `event_id`). Aggregerende queries over duizenden rijen — dashboards, +> CSV-exports, counts — run directly against these columns instead of +> joining through `form_schemas`. Every other form-builder child table +> (`form_schema_sections`, `form_fields`, `form_values`, `form_value_options`, +> `form_submission_section_statuses`, `form_submission_delegations`, +> `form_schema_webhooks`, `form_webhook_deliveries`) resolves its tenant +> via the FK-chain strategy added to `OrganisationScope` in the same +> addendum. + --- ### `form_submission_section_statuses`