Commit Graph

9 Commits

Author SHA1 Message Date
af8a9da038 feat(form-builder): form_field_bindings table + polymorphic owner + cascade observer
WS-5a commit 1 of 4 per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 Q3.

Creates the relational home for what was form_fields.binding JSON and
form_field_library.default_binding JSON. Owner discriminator is polymorphic
morph (owner_type/owner_id) — the pattern the rest of WS-5 (5b validation_rules,
5d options) will reuse.

Migration backfills rows from both JSON sources in a single transaction and
is genuinely reversible (rollback reconstructs the JSON). Old columns remain
in place until commit 3 has switched all readers.

Pattern B (binding=null) is represented by absence of row. mode enum covers
entity_owned / mirrored only.

Cascade on owner delete via observer — bindings are physical state, not
historical audit. FormFieldBindingScope enforces multi-tenancy via UNION over
both owner chains (form_field → schema → org OR form_field_library → org) —
Q2's declarative tenantScopeStrategy() can't walk morph parents.

Tests: migration forward/back, morph relation, cascade observer, scope
isolation, enum coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:43:11 +02:00
b688ec26f0 feat(scope): declarative FK-chain strategy for OrganisationScope, register on 14 models per addendum Q2 + D-03/D-04
Refactors OrganisationScope to support a declarative, recursive FK-chain
resolver and registers the scope on 14 models that previously relied on
caller-discipline for tenant isolation.

Scope resolver (app/Models/Scopes/OrganisationScope.php):
Models now declare their strategy via:

    public static function tenantScopeStrategy(): array
    {
        return ['column' => 'organisation_id'];           // terminal
        // OR
        return ['via' => FormSchema::class, 'fk' => 'form_schema_id'];
    }

The apply() path walks the chain recursively, building whereIn subqueries
against parent models until it hits a column-based strategy. Max 3 hops;
deeper chains raise App\Exceptions\TenantScopeResolutionException. The
walker accepts BOTH the new tenantScopeStrategy() and the legacy
$organisationScopeColumn property at every hop — so PersonIdentityMatch
can chain via Person, which still uses the legacy event_id bridge, without
requiring Person/Event/Shift/FestivalSection/TimeSlot to migrate to the
new convention in this work package. That migration is a separate
backlog ticket — explicitly scope-controlled per the addendum.

Fourteen newly-scoped models:

  Form-builder child models (D-03):
    FormSchemaSection             via FormSchema                    (1 hop)
    FormField                     via FormSchema                    (1 hop)
    FormSubmission                column organisation_id (Commit 2)
    FormValue                     via FormSubmission                (1 hop)
    FormValueOption               via FormValue -> FormSubmission   (2 hops)
    FormSubmissionSectionStatus   via FormSubmission                (1 hop)
    FormSubmissionDelegation      via FormSubmission                (1 hop)
    FormSchemaWebhook             via FormSchema                    (1 hop)
    FormWebhookDelivery           via FormSubmission                (1 hop)

  Event-data models (D-04 event-data subset):
    ShiftAssignment               via Shift (legacy festival_section_id)
    ShiftWaitlist                 via Shift
    VolunteerAvailability         via TimeSlot (legacy event_id)
    PersonSectionPreference       via FestivalSection (legacy event_id)
    PersonIdentityMatch           via Person (legacy event_id)

Note — task directive specified VolunteerAvailability "via: Event, fk: event_id",
but the table has no event_id column (only person_id + time_slot_id).
Rerouted via TimeSlot, which carries the legacy event_id bridge; same
end result, correct FK.

Security-relevant callers made explicit:
  PublicFormSchemaResource::toArray() now eagerly loads fields + sections
  with withoutGlobalScope(OrganisationScope::class). Prior to this commit
  the public form endpoint silently relied on those relations being
  unscoped. The PublicFormCrossOrgScopeTest pre-existing assertions still
  pass — behaviour unchanged, intent now explicit.

Test fix: FormSchemaApiTest::test_publish_sets_is_published_true was
flaky (factory randomly picked EVENT_REGISTRATION which requires
bindings). Pinned to USER_PROFILE for determinism; PurposeSchemaLifecycleTest
covers the binding-enforcement path.

Test flip: MultiTenancyTest::test_form_schema_webhook_is_not_globally_scoped
renamed to is_scoped_via_fk_chain and asserts the new behaviour: scope
filters by route org, withoutGlobalScope() still exposes cross-org rows.
The test's original purpose ("pin current behaviour so a future refactor
is intentional") is now satisfied by Commit 3 being that intentional
refactor.

Docs:
  SCHEMA.md §3.5.11 Rule 5 — tenantScopeStrategy() convention documented;
    the 14 newly-scoped models enumerated; link to addendum Q2.
  ARCH-FORM-BUILDER.md §4.14 — new section "Multi-tenancy scope chain"
    with the hop-count table for all 14 chains and the withoutGlobalScope
    pattern for cross-org callers.

Tests: tests/Feature/MultiTenancy/ScopeLeakageTest.php — two orgs with
fully-populated record chains down to each of the 14 leaf models; asserts
scoped queries never cross, withoutGlobalScope still does. Plus: three-
hop chain (FormValueOption) explicitly exercised, legacy-column bridge
verified, over-deep chain raises TenantScopeResolutionException. 16 tests /
31 new assertions. Full suite: 1000 passed (2706 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:08:33 +02:00
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
a92ddc48ec refactor(schema): migrate eleven pivot/EAV tables to ULID per addendum Q1
Retires the "integer AI PK for join performance" exception documented
in earlier migrations and SCHEMA.md §3.5.11 Rule 1. Every business and
pivot table now uses ULID primary keys, per
/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1.

Tables migrated (WS-1 A-01 through A-11):
- Pure pivots: organisation_user, event_user_roles, crowd_list_persons,
  event_person_activations
- Model-backed: user_organisation_tags, person_section_preferences,
  mfa_backup_codes, mfa_email_codes, form_submission_section_statuses,
  form_values, form_value_options

Migration pattern: one new migration per table (plus one combined for
the form_values / form_value_options FK pair), timestamped today,
dropping + recreating with the new ULID PK. Pre-launch — no backfill
required. Original migrations remain in place; the new migrations
apply in timestamp order for a clean schema history.

Pivot model correction (addendum drift):
The addendum's "no model required for pure pivots" reading did not
account for Laravel's BelongsToMany::attach() — it cannot auto-generate
a pivot ULID without a Pivot subclass. Minimal Pivot classes under
app/Models/Pivots/ (OrganisationUser, EventUserRole, CrowdListPerson,
EventPersonActivation) carry HasUlids so attach() works. The six
belongsToMany relations (User.organisations / .events, Organisation.users,
Event.users, CrowdList.persons, Person.crowdLists) now ->using(...) the
appropriate Pivot class. DB::table()->insert() on event_person_activations
in DevSeeder populates the ULID inline via Str::ulid(). FormValueObserver
uses bulk FormValueOption::insert() which bypasses model events — ULIDs
are now generated inline there too.

Docs:
- SCHEMA.md §3.5.11 Rule 1 rewritten to mandate ULID on pivots too, with
  legacy note citing the addendum.
- All eleven table entries updated from "int AI PK" to "ULID PK" with
  addendum Q1 references.
- form_values and form_submission_section_statuses prose blocks updated
  to drop the retired ARCH §4.4 / "high-volume pivot" rationale.
- form_value_options.form_value_id column type corrected from
  "int FK" to "ULID FK".

Tests: tests/Feature/Schema/UlidPrimaryKeyTest.php covers HasUlids trait
presence, ULID shape + 26-char Crockford pattern, Route::bind resolution,
distinct + sortable pivot ULIDs, attach() auto-generation on pure pivots,
and the A-10/A-11 FK chain. 10 tests / 28 new assertions. Full suite:
977 passed (2662 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:38:08 +02:00
b9343f6eec refactor(form-builder): drop custom purpose escape from schemas
Reduces the FormPurpose vocabulary from 22 variants + a `custom` escape
to the seven v1.0 purposes registered in the new PurposeRegistry.

- Purge migration deletes any form_schemas row whose `purpose` is not
  in the v1.0 set (cascades through form_fields, form_submissions,
  form_values, form_value_options, form_schema_sections,
  form_submission_section_statuses, form_submission_delegations,
  form_schema_webhooks, form_webhook_deliveries via existing FK).
- Drop migration removes the `custom_purpose_slug` column + its index.
- Both migrations declare their `down()` as a hard failure — we do not
  support reversing a purge (pre-launch, no production data).
- `FormPurpose` enum slims to the seven cases; the legacy helpers
  (defaultSubmissionMode / defaultSubjectType / allowsPublicAccess)
  now delegate to PurposeRegistry so callers keep working.
- FormSchema fillable / FormSchemaResource / StoreFormSchemaRequest /
  UpdateFormSchemaRequest / FormSchemaFactory drop every reference to
  `custom_purpose_slug` and the `custom` purpose.
- VerifyFormsDataIntegrity drops the custom-slug mismatch check and
  sources the subject-type allow-list from PurposeRegistry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:35:37 +02:00
71d2b4294d feat(form-builder): schema drift detection + PUT auto_save_count
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>
2026-04-17 23:03:12 +02:00
a3f35e533f feat(form-builder): identity-match listener + identity_match_status column
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>
2026-04-17 22:55:35 +02:00
6e89b0ccf7 test(form-builder): feature suites + integration contracts incl. FORM-02 (§31.10)
Phase 6 of S2b. 37 new tests, 820 → 857 passing across the suite.

Feature suites (api/tests/Feature/FormBuilder/):
- FormSchemaApiTest: CRUD, publish/unpublish, rotate-public-token (with
  grace window), edit-lock conflict, typed-confirmation delete, 401 on
  unauthenticated, 403 on outsider.
- FormFieldApiTest: create, reorder, binding-change guard (422 w/o force,
  200 with force), conditional_logic cycle rejection, 401 unauth.
- FormSubmissionApiTest: draft → values → submit stores schema snapshot +
  version; review records reviewer; delegation creates active row; draft
  update blocked for non-subject non-delegatee (403).
- FormValueSecurityTest: FieldAccessService hides admin-only fields from
  non-admin; subject-self bypass; admin-only field leaks through neither
  admin list nor non-admin detail responses (§22.9 intent).
- PublicFormApiTest: portal-visible non-admin fields only; unknown token
  → 404; happy-path submission; expired-previous-token → 410; grace
  window still allows submission.
- FormSchemaWebhookApiTest: url/secret NEVER returned in resources;
  DeliverFormWebhookJob rejects 10.x private-ip SSRF (response_body_excerpt
  logs rejection).
- FilterRegistryApiTest: response shape includes tags + form_field
  sources; form_field filter registers.

Integration contract (§31.10):
- TagPickerSyncListenerTest: 5 cases proving (a) no-op on user_id=null,
  (b) sync on submit, (c) deferred sync via
  PersonIdentityService::confirmMatch, (d) organiser_assigned tags
  preserved on rebuild, (e) idempotent rerun.

Fixes discovered while writing tests:
- SyncTagPickerSelectionsOnSubmit: removed hardcoded connection='redis'
  so tests run via sync queue (QUEUE_CONNECTION fallback).
- FormSubmissionService: corrected FormSubmissionReviewed / DraftUpdated
  event signatures to match S1 event classes.
- FormSubmission model: added schema_version_at_submit / snapshot /
  anonymised_at / submission_duration_seconds / auto_save_count to
  $fillable so bulk operations + factory states populate consistently.
- FormSchema: added version, edit_lock_user_id, edit_lock_expires_at to
  $fillable; factory now sets version=1 explicitly.
- FormValueService: public submission path (actor=null) enforces
  is_portal_visible=true AND is_admin_only=false at the write layer
  instead of running FieldAccessService against a null user.
- MigrationRollbackTest: target the S2a drop migration by filename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:27:27 +02:00
85815ccb16 feat(forms): add Eloquent models, observer, events, activity-log helpers
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>
2026-04-17 12:35:41 +02:00