bdb89a2479c0e4a87507d0e4fe084b1249477736
22 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 192353f4bc |
feat(form-builder): admin UI completion — server filters, KPIs, resource expansion (WS-6 sessie 3c)
Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.
Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
listener_class. orgIndex + platformIndex apply them via a single
applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
resolved_by_user_name, dismissed_by_user_name, exception_trace,
retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
data (timestamp, actor name, outcome, exception details) inside
retry_history[]. Index payloads omit the field via whenLoaded() to keep
list responses lean.
Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
endpoint contract. FormFailuresKpis is now { open, resolved_30d,
dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
filtering, adds listener_class + date-range filter inputs, and renames
the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
header, surfaces an expandable stack-trace card, names the resolved/
dismissed actor in the timeline, and replaces the "v1 placeholder"
retry-history card with a full per-attempt timeline.
ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
rules. `pnpm lint` now runs successfully (was previously broken — the
package.json script referenced a missing config). The 80 baseline
violations across the codebase are pre-existing and out of scope for
this session.
Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
dry-run unchanged at 354 (pre-Task-1 baseline from
|
|||
| b47e096a55 |
feat(form-builder): retry history table + integration (WS-6)
Per-attempt retry history (timestamp, user, outcome, exception detail
if failed) replaces the counter-only retry_count tracking.
Changes:
- New `form_submission_action_failure_retry_attempts` table (cascade on
parent delete, nullOnDelete on user). Explicit short FK names
(`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed
MySQL's 64-char identifier limit.
- New FormSubmissionActionFailureRetryAttempt model + factory +
succeeded() state.
- Parent FormSubmissionActionFailure gets retryAttempts() HasMany
relation (latest('attempted_at')).
- New FormFailureRetryService centralises the retry-flow logic. Both
the API controller and the artisan command delegate to it. Service
writes a retry_attempt record per attempt; parent's retry_count
stays as denormalised cache for index-view performance.
- Successful retry: attempt(succeeded) + parent.retry_count++ +
parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note
("Geslaagde retry door {actor.name}" or "Geslaagde retry
(geautomatiseerd)" for command-line invocation without an actor).
- Failed retry: attempt(failed) with NEW exception details +
parent.retry_count++. Parent's exception_class/_message stay
audit-immutable — they represent the FIRST failure.
- canBeRetried() now correctly checks both resolved_at AND
dismissed_at (sessie 2's open question Q2 closure).
- New FailureNotRetriableException (controller → 422) and
ParentSubmissionGoneException (controller → 410) for cleaner
flow control.
12 new tests:
- FormSubmissionActionFailureRetryAttemptTest (5 unit tests)
- RetryFlowProducesRetryAttemptsTest (7 integration tests covering
succeeded path, failed path, resolved/dismissed blocking,
multiple-retries chronological ordering, canBeRetried truth tables)
Pre-existing tests touched:
- FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state
— updated to reflect Q2 closure (resolved now blocks too).
- Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table
— child table must drop before parent (FK constraint).
- 5 backfill test step-counts bumped +1 (new migration sits at top).
SCHEMA.md → v2.9. Schema dump regenerated.
Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| a791a276fa |
fix(form-builder): canonicalize JSON for byte-stable storage (WS-6)
MySQL 8.0 JSON columns may reorder associative-array keys on round-trip. For audit-immutable values (schema snapshots, webhook payloads, activity log diffs), this is corrupting: re-emits produce different byte sequences for the same logical content. Introduced JsonCanonicalizer (recursive ksort on associative arrays; numeric-indexed lists preserve order) and applied at every writer site that produces byte-stable JSON: - FormSubmissionService: canonicalize the schema_snapshot array before storage (audit-immutable per ARCH §4.3, RFC-WS-6 v1.1). - FormField::logFieldChange / FormSchema::logSchemaChange: canonicalize activity-log properties before withProperties() so old/new diffs read back byte-stable. - BindingActivityLogger: canonicalize both the pass-level and per-binding activity properties. - FormWebhookDispatcher: canonicalize payload_snapshot before storage (delivery-time HMAC re-encodes the same canonical bytes). - DeliverFormWebhookJob: switched json_encode to JsonCanonicalizer::encode for the HMAC-signed body, so the signature is byte-stable across re-deliveries and reproducible by receivers from the same logical payload. Sites NOT canonicalized (deliberate): - form_schemas.settings — opaque UI config; key order has no semantic meaning, no byte-stability requirement. - form_schemas.translations / form_fields.translations — read by display layer; key order doesn't matter. - form_templates.schema_snapshot — user-supplied input via store/ update; user is the source of truth, not audit-immutable in the same way as form_submissions.schema_snapshot. Reverted the 7 assertEquals workarounds from session 2.6: - ConditionalLogicActivityLogPayloadTest - ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json - FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table - FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only - FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change - FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot - FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options Each now uses assertSame on JsonCanonicalizer::encode of both sides — byte-stable comparison meaningful regardless of MySQL JSON storage behavior. New regression test SchemaSnapshotByteStableAcrossReemitsTest exercises the contract end-to-end: complex schema with bindings, validation rules, options, conditional logic, submitted; reads schema_snapshot via three roads (Eloquent cast, fresh model, raw bytes) and asserts the canonical encode is identical. ARCH-FORM-BUILDER.md §4.6.1 gets a "Byte-stability" sub-section explaining what's canonicalized and why. Test count: 1388 → 1400 (+11 JsonCanonicalizer unit, +1 snapshot regression). Larastan clean. Rector dry-run unchanged at 355. Refs: WS-6 session 2.6 deviation #4 cleanup, RFC-WS-6 v1.1 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| d2059e3cff |
feat(form-builder): per-schema default_crowd_type_id replaces silent oldest() heuristic (WS-6)
Session 2's PersonProvisioner picked CrowdType::oldest() for the org — silently wrong for multi-crowd_type orgs (Volunteer + Crew + Press are three distinct crowd_types in one org). Schemas now declare their target crowd_type explicitly via form_schemas.default_crowd_type_id. RequiresDefaultCrowdType publish guard prevents misconfigured event_registration schemas from publishing. PersonProvisioner: oldest() fallback removed entirely. Misconfiguration throws no_default_crowd_type at runtime; publish guard prevents it at config time. Migration uses a plain ulid() column without DB-level FK because SQLite's table-rebuild on ALTER ADD FOREIGN KEY cascade-deletes form_fields rows (form_fields.form_schema_id has cascadeOnDelete on form_schemas). Application-level integrity via FormSchema::defaultCrowdType() belongsTo + the publish guard + the runtime failsafe — three load-bearing checks, none of which require the DB-level constraint. Three pre-existing migration backfill tests bumped step counts +1 to account for the new migration sitting between WS-5c and WS-5d: FormFieldBindingMigrationTest (16→17, 14→15), FormFieldConfigBackfillAndDropTest (11→12), FormFieldValidationRuleBackfillTest (14→15), ConditionalLogicBackfillTest (5→6). Six event_registration test fixtures updated to set default_crowd_type_id to satisfy the new publish guard. FormBuilderDevSeeder.resolveDefaultCrowdTypeId() — VOLUNTEER → first-active → create-as-needed fallback chain; documented contract for future seeders. SCHEMA.md updated to v2.7. Refs: RFC-WS-6.md v1.1 §3 Q8 addendum (Task 4 of this session) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| c5b0210ae7 |
feat(form-builder): add FormSubmissionActionFailure model + apply_status casts (WS-6)
- FormSubmissionActionFailure: audit model, no organisation_id (FK-chain tenancy per RFC V3), open/resolved/dismissed scopes, canBeRetried() helper. Morph alias 'form_submission_action_failure' registered for future activity-log subject references. - FormSubmission: apply_status (ApplyStatus enum cast), apply_completed_at (datetime), actionFailures() HasMany, scopePendingApply(). Refs: RFC-WS-6.md §3 (Q5), §4 (V3) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| e7c9482474 |
refactor(form-field): drop form_fields.options + form_field_library.options
Final WS-5d cleanup. The JSON columns that have been unread since
commit 3 are now physically dropped on both source tables. Their
canonical rich-shape lives in form_field_options, accessed
exclusively through the morphMany relation.
Defensive sweep: any lingering translations.{locale}.options key in
either source table's translations bag is stripped. Commit 2's
backfill should already have done so exhaustively; this is
belt-and-braces.
Rollback re-creates the columns as nullable JSON but leaves them
empty. Pair with commit 2's rollback to restore the pre-WS-5d data
shape on every owner row.
The commit-3 getOptionsAttribute accessor-bridge on FormField +
FormFieldLibrary is removed — Eloquent's getAttribute() resolution
now naturally falls through to the morphMany relation since there's
no underlying column to shadow it. New regression test
FormFieldOptionsAccessTest asserts $field->options resolves to an
Eloquent Collection of FormFieldOption instances and lazy-loads in
exactly 2 queries (1 parent + 1 lazy-load options) on a fresh fetch
without with() preload. Same trio for FormFieldLibrary.
Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new drop_form_field_options_json_columns migration on the
rollback stack.
Documentation:
- SCHEMA.md v2.6: form_field_options table documented; options row
removed from form_fields and form_field_library; morphMany
relations updated; cross-references to ARCH-FORM-BUILDER §17.6
and addendum §Q3 WS-5d Uitvoering added on both source-table
docblocks.
- ARCH-FORM-BUILDER.md v1.8: new §17.6 "Field options (relational)"
mirrors the §17.4 / §17.5 relational-sibling structure with
sub-sections 17.6.1 rationale, 17.6.2 table + catalogue, 17.6.3
service / scope / cascade / activity log, 17.6.4 snapshot
embedding, 17.6.5 external API contract. Existing Webhooks
section renumbered from §17.6 to §17.7.
- ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md: "Uitvoering — WS-5d
(2026-04-27)" section added. Eight paragraphs covering the
snapshot atomic rewrite, strict-fail backfill dispatch, dual
activity-log emit, four-sibling base-class extraction warrant,
commit 0 dead-code precondition, the temporary getOptionsAttribute
accessor-bridge pattern (with reusability note for future
JSON→relational refactors), the dev-seeder vergoedingstype RADIO
normalisation (drift correction explicitly distinguished from the
parallel apps/app RegistrationFieldTemplate description domain),
and the WS-5 family completion note.
- BACKLOG.md: FORM-BUILDER-LIBRARY-AUDIT-LOG entry extended to four
services (adds library.options_replaced); new
FORM-BUILDER-MORPH-SCOPE-BASE-CLASS entry added as the WS-5d
follow-up now that all four concrete morph-scope siblings exist.
Tests: 1193 → 1208 green (+15 across commits 3+4+5; this commit alone:
+2 from the regression test).
This completes the WS-5 family.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| bb9242fd6e |
refactor(form-field): resources + snapshot + validator read form_field_options
Atomic reader switch. All call paths that previously read
form_fields.options / form_field_library.options from the JSON column
now read through FormFieldOptionService::toJsonShape() via the
morphMany relation:
- FormFieldResource + FormFieldLibraryResource +
PublicFormSchemaResource emit the rich-shape array
- FilterRegistryController emits rich shape uniformly (no flat-array
carve-out for filter-UI compatibility — preflight scan confirmed
zero portal/app consumers, S5 territory)
- FormFieldRuleBuilder plucks values from the relation for in:options
rule construction
- FormSubmissionService::buildSnapshot writes rich-shape options into
snapshots and strips translations.{locale}.options from each field's
translations bag (defensive — commit 2 backfill already did the
bulk strip)
- Four FormFieldRequest variants accept array-of-spec-objects,
validate shape in after() via FormFieldOptionService::assertSpecsValid,
and hand off to FormFieldOptionService::replaceOptions for writes
- FormFieldService::create + update extract option specs from the
request data and route through the service after the FormField row
is persisted
FormField and FormFieldLibrary $casts no longer include 'options'; the
JSON column is no longer cast. Options removed from $fillable on both
models so ::create() / ::fill() / mass assignment can no longer touch
the legacy column. Both models gain a getOptionsAttribute() accessor
that resolves $model->options to the eager-loaded morphMany collection
— required because Eloquent's getAttribute() prefers a real DB column
over a relation method, and the JSON column lives on the table until
WS-5d commit 5 drops it.
Activity log — dual emit per §6.7 / §17.4.2 / §17.6.3:
- field.updated carries old.options / new.options diff via
toJsonShape() reconstruction, byte-equal JSON compare to avoid
cosmetic false positives. Field updates that don't touch options
omit the key entirely
- field.options_replaced emits inside replaceOptions() on FormField
subject only; library subject writes silent (mirrors the WS-5b /
WS-5c convention)
JSON columns (form_fields.options, form_field_library.options) remain
present but unread — column drops land atomically in commit 5.
Two pre-existing test fixtures that seeded options via the JSON column
(FormFieldApiTest + PublicFormValidationTest) migrated to the
spec-array path: FormField::factory()->withOptions([...]) where the
options live on the field, or explicit spec-array request bodies for
HTTP tests.
Tests: 1193 → 1206 green (+13 tests / +28 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 11588623c5 |
feat(form-field): add form_field_options table, service, scope, cascade
Fourth and final WS-5 sibling. Polymorphic morph-owned table for the
RADIO / SELECT / MULTISELECT / CHECKBOX_LIST option rows, shared
between form_fields and form_field_library via the owner_type
discriminator. Matches the WS-5a (bindings) / WS-5b (validation_rules
+ configs) pattern one-for-one: dedicated service as single writer,
UNION-over-two-owner-chains scope, shared cascade observer.
Row shape:
- value canonical storage value (string ≤255, UNIQUE per owner)
- label default-locale display label (string ≤255)
- sort_order int unsigned
- translations JSON { "<locale>": "<translated label>" }
The UNIQUE(owner_type, owner_id, value) index ffo_owner_value_unique
is the seed-bug guard — duplicate values per field have no semantic
meaning and must fail at both the service layer (assertSpecsValid)
and the DB level.
Activity log: field.options_replaced emits on FormField subject only,
per the §6.7 WS-5a / §17.4.2 WS-5b convention that library-level
changes are silent in activity log.
No production reads yet. The form_fields.options and
form_field_library.options JSON columns remain the active source of
truth until the commit-3 reader switch — accessing $field->options
still resolves through the JSON cast in commit 1, so model tests
exercise the new morphMany via $field->options() (explicit relation
call). Both FormField and FormFieldLibrary now carry an `options`
morphMany alongside `bindings`, `validation_rules`, and `configs`.
Cascade: FormFieldChildTablesCascadeObserver gains form_field_options
as the fourth child cleaned on owner delete (both FormField soft/
force-delete and FormFieldLibrary delete).
Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new create_form_field_options_table on the migration stack.
Base scope-class extraction across the four siblings — deliberately
deferred to a follow-up work package per addendum §17.4.3 / §17.5.3.
Now that all four concrete implementations exist, the "what actually
varies" question can be answered empirically.
Tests: 1158 → 1182 green (+24 tests / +42 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 079d10975b |
refactor(form-builder): strict validator + drop form_fields.conditional_logic JSON column
WS-5c commit 3 of 4. FormRequests (Store/Update) now reject bad
conditional_logic trees at the HTTP boundary — the `after()` hook
unwraps the `show_when` envelope, normalises legacy `{all|any: [...]}`
group shape to the service's internal form, and delegates to
`FormFieldConditionalLogicService::assertSpecsValid()`. Unknown
operators, root conditions, empty groups, and unknown field_slug
references produce a 422 with a readable error before any write.
`form_fields.conditional_logic` JSON column dropped. FormField model
`$fillable` and `$casts` no longer mention the column; factory default
no longer writes `null` to it. Snapshot fixtures in the dev seeder and
the legacy-forms migration command keep `conditional_logic` in their
snapshot JSON shape — that's the schema_snapshot contract, not the DB
column.
FormFieldController now maps InvalidConditionalLogicSpecException to
422 alongside FrozenSchemaException / CyclicDependencyException.
Rollback path: roll back WS-5c commits 1–3 together. Partial rollback
(drop-column reversed but backfill still applied) is not a supported
state — matching the WS-5a/b precedent on the family's full-rollback
contract.
Tests: 6 new (strict FormRequest rejection cases + JSON-column drop
assertion). Rollback step counts in WS-5a/b migration tests bumped +1
for the drop_conditional_logic_json_column migration. Baseline
1142 → 1148 green (3085 → 3099 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 2064b9901e |
feat(form-builder): form_field_conditional_logic_{groups,conditions} tables + OrganisationScope cap raise to 5
WS-5c commit 1 of 4 — relational infrastructure for the conditional- logic tree that replaces form_fields.conditional_logic JSON (ARCH- FORM-BUILDER §8; addendum Q3 WS-5c). Tables: groups (nesting via parent_group_id) + conditions (leaves, value JSON nullable for empty/not_empty). Simple FK to form_fields — addendum Q3 explicitly excludes form_field_library from conditional_ logic scope, so no polymorphic morph here. OrganisationScope cap raised 3 → 5 hops. The conditions chain is 4 hops (condition → group → field → schema → organisation_id column) and the new cap gives headroom for future deeper trees without denormalising form_field_id onto conditions. Cascade observer (FormFieldChildTablesCascadeObserver) extended to physically delete the new groups table on FormField delete (hard or soft). Conditions cascade automatically via the group_id FK on the groups table. Factories: FormFieldConditionalLogicGroupFactory, FormFieldConditional LogicConditionFactory, and FormFieldFactory::withConditionalLogic($tree) for concise test fixtures. Tests: 16 new under tests/Feature/FormBuilder/ConditionalLogic/ (relation, scope, cascade, enum catalogue). 3 new scope-cap tests in ScopeLeakageTest verify 4/5-hop chains pass and 6-hop throws. Hardcoded rollback step counts in WS-5a/b migration tests bumped for the 2 new WS-5c migrations. Baseline 1104 → 1122 green (2988 → 3032 assertions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| d494478c08 | feat(form-builder): form_field_configs relational table + non-validation key split + drop validation_rules JSON columns | |||
| fedaed1b32 | feat(form-builder): form_field_validation_rules table + polymorphic owner + scope + cascade | |||
| 61719bf8bf | refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns | |||
| 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>
|