af8a9da0386681d96e3c13d20bc7feefdc85bbe6
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> |
|||
| 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>
|
|||
| 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>
|
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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>
|