6933e6d70092991db71f37b30786be220468c768
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 6933e6d700 |
feat(form-builder): FormFieldBindingService + library-to-field row copy + snapshot writer
WS-5a commit 2 of 4. FormFieldBindingService owns all writes to the relational binding table. Validation against config/form_binding.php entity-column registry lives here (ARCH §6.2). FormFieldService::insertFromLibrary now calls copyBindings instead of hydrating JSON — the Q3 row-copy mandate. Library and field bindings share the same table; insertion is a row-clone operation. Snapshot writer (FormSubmissionService::buildSnapshot) serialises bindings via toJsonShape so schema_snapshot JSON keeps its ARCH §4.6.1 / §6.3 contract. No snapshot format change. API resources source binding output from the relational table via the same serialiser — external shape preserved. Tests: service transactional behaviour, copyBindings preservation, snapshot parity, API resource parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 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>
|
|||
| 55ba4f24c0 |
test(form-builder): cover purpose registry and morph-map alignment
- PurposeRegistryTest: all seven purposes load with expected shape; `get()` throws PurposeNotFoundException on unknown slug; `allSubjectTypes()` returns exactly [artist, company, person, user]; `publicAccessibleSlugs()` is only `[event_registration]`. - PurposeSchemaLifecycleTest: data-provider-driven create → publish for all seven purposes; negative tests for event_registration (three missing bindings) and supplier_intake (company.name missing); partial binding test reports only the missing subset. - CustomPurposeEscapeRemovedTest: column gone, config file gone, FormPurpose::CUSTOM gone, store endpoint rejects `'custom'`, resource payload omits the field. - SubjectTypeRegistryConsolidationTest: submission validation accepts registry subject types, rejects everything else including the legacy `event` alias that used to be allowed. - MorphMapAlignmentTest: compile-time guard that every PurposeRegistry::allSubjectTypes() alias appears in the morph-map and in AppServiceProvider::PURPOSE_SUBJECT_FQCN. - FormPurposeTest rewritten to cover the seven v1.0 cases and the registry-delegation helpers (now extends Tests\TestCase for the container). - Public/listener tests swap the removed PUBLIC_RSVP / PUBLIC_COMPLAINT / FEEDBACK references for valid v1.0 purposes, preserving their negative-path assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| b6a3a17b0a |
feat(form-builder): detect duplicate submissions by email on same form schema
Informational hint on the confirmation page when the same email has already submitted the form. Not a block — the submission proceeds normally. Privacy-safe: only shown to the submitter themselves. Scope: same form_schema_id only. Cross-form/cross-event detection would leak info about other forms. - New FormSubmissionDuplicateDetector service queries by form_submissions.public_submitter_email (trim + case-insensitive) scoped to the schema, status=submitted, excluding the current submission. Errors are swallowed + logged so a detector failure never blocks the submit response. - PublicFormSubmissionController enriches the submit response by setting a transient duplicate_submission_data attribute on the submission before resource serialisation. - PublicFormSubmissionResource serialises a duplicate_submission block with count, first_submitted_at, plus backend-authored Dutch title + body (plural-agreement + IntlDateFormatter for "23 april 2026"-style long-form dates). Null when no priors, no email, or detector error. - DuplicateSubmissionHint.vue (warning-typed tonal VAlert) above IdentityMatchBanner on FormConfirmation. Prefers backend copy with Intl-based Dutch date fallback for safety. - 16 new backend assertions across the detector and the full submit-response flow; 5 new Vitest assertions for the hint. Note on scope: spec suggested extracting email from values via schema binding; the codebase's public flow captures submitter email in a guaranteed column (public_submitter_email) populated by the stepper's Contactgegevens step. Using that directly is both simpler and more correct for the duplicate-by-submitter semantic. When FORM-05's binding-based extractor lands, this detector can migrate without changing its public API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| fda8033633 |
refactor(form-builder): make identity-match listener synchronous
TriggerPersonIdentityMatchOnFormSubmit was queued, which meant identity_match_status stayed null in the HTTP response body for public event_registration submissions — the portal confirmation page rendered without the IdentityMatchBanner until a queue worker caught up. Eager state transitions belong in the request lifecycle. The listener is now an orchestrator that writes pending/matched/none synchronously. When FORM-05 proper lands with heavy matching logic (PersonIdentityService::detectMatchesByValues, fuzzy name matching over the whole org), the heavy work will dispatch as a separate queued job from within this same listener — sync state transition + async resolution. - Remove ShouldQueue, InteractsWithQueue trait, $queue property - Existing try/catch error containment unchanged (sibling listeners §31.10 tag sync, §31.3 shift provisioning keep running) - Add HTTP-response contract test locking in sync behaviour: submit returns data.identity_match.status='pending' on first response, without any queue worker running. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 1a87871e94 |
feat(form-builder): extend public form backend for S3a PR 2
- Seed AVAILABILITY_PICKER and SECTION_PRIORITY demo fields in the
event_registration showcase, and augment seedEchtFeesten with a
parent-level VOLUNTEER time slot pair + a standard registration-
visible section whose name duplicates a child section so the
PublicFormController dedup path is exercised end-to-end.
- Validate SECTION_PRIORITY value shape in FormValueService: arrays of
{ section_id, priority } with unique section_ids + priorities in 1..5,
max 5 entries, and section_ids scoped to the schema's event tree
(parent + children). Error envelope is the standard VALIDATION_FAILED
FieldValidationException shape so the portal renders errors next to
the field.
- Enrich admin-facing FormSubmissionResource with a nested identity_match
block mirroring the PublicFormSubmissionResource contract (status only;
leaves room for future matched_user_id / confidence).
- Lock in the FORM-05 stub contract with 6 tests against the existing
TriggerPersonIdentityMatchOnFormSubmit listener (no new listener was
needed — the current one already writes 'pending' for public
event_registration submissions per ARCH §31.1).
- 24 new backend assertions across seeder, shape validation, listener
state matrix, and resource serialisation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 63d08c8bde |
feat(form-builder): public draft/save/submit split + sub-endpoints + validation
S2c D2, D3, D4, D8 — the meat of the public API rewrite.
Draft / save / submit split (D4):
- POST /public/forms/{public_token}/submissions
Creates a draft. idempotency_key is now REQUIRED; second POST with
the same key returns the existing draft (HTTP 200 vs 201 for fresh).
UniqueConstraintViolationException caught for race-safe replay.
- PUT /public/forms/{public_token}/submissions/{submission_id}
Auto-save. Partial updates only — each PUT writes just the
slugs in the body. Status stays 'draft'; auto_save_count++.
- POST /public/forms/{public_token}/submissions/{submission_id}/submit
Final submission. Merges body values with already-saved values,
runs strict rule set against the merged map, then calls
FormSubmissionService::submit which fires the lifecycle events
(tag sync, identity match). Rate-limited per IP per token per hour.
Access rules: submission must belong to the resolved schema; status
must be 'draft' (409 SUBMISSION_ALREADY_SUBMITTED otherwise); schema
still accepting submissions.
Sub-endpoints (D2, D3):
- GET /public/forms/{public_token}/time-slots
Volunteer-only, festival-aware (parent + children). Reads straight
from TimeSlot model — no org-coupled service to extract from. Out:
{id, name, date, start_time, end_time, duration_hours, event_id,
event_name}.
- GET /public/forms/{public_token}/sections
show_in_registration=true, type=standard, deduplicated by name
across festival children.
Dynamic per-field validation (D8):
- FormFieldRuleBuilder builds Laravel rule arrays from form_fields.
strict() enforces is_required + in:options + type rules (email,
url, numeric, date, boolean, phone regex); relaxed() is the
auto-save variant that drops required-ness.
- StartPublicDraftRequest (required idempotency_key),
SavePublicDraftRequest (relaxed rules, values optional),
SubmitPublicSubmissionRequest (relaxed rules at body level — the
controller merges the body with saved values and runs the strict
validator on the full map so submit with an empty body still
passes when everything was auto-saved).
- FormValueService backs the request layer up with deeper enforcement
of validation_rules JSON (min/max/regex) + is_unique. Throws
FieldValidationException (422) which renders via the D6 envelope.
PublicFormTokenResolver centralises the grace-window logic; every
public endpoint resolves through it so the standardised exceptions
bubble uniformly.
Routes: 6 total under /public/forms/ (up from 2). Tests:
PublicFormApiTest's existing submit test retrofitted to the three-step
flow; 857 tests still green.
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> |
|||
| cd7a804024 |
test(forms): model tests, multi-tenancy, migration rollback (Phase 9)
UserProfileTest: belongs-to user, fillable/non-fillable boundaries, settings cast, lastSubmittedAt accessor (null + max from user-subject submissions only, ignoring drafts and is_test rows). FormSchemaTest: ULID PK, OrganisationScope filtering, polymorphic owner resolution to Event, purpose enum cast, hasMany fields/submissions, and logSchemaChange() actually creates an activity-log entry. FormFieldTest: belongs-to schema, field_type stored as string (not DB enum), binding/translations array casts, hasMany values, soft-delete preserves historical values, logFieldChange() creates an entry. FormSubmissionTest: belongs-to schema, polymorphic subject resolution, status enum cast, schema_snapshot array cast, hasMany values. FormValueTest: belongs-to submission/field, value array cast, hasMany options pivot rebuilt by observer, unique-pair DB constraint enforced. MultiTenancyTest: OrganisationScope correctly filters FormSchema / FormTemplate / FormFieldLibrary by route-resolved organisation. Pins the FormSchemaWebhook un-scoped behaviour explicitly so a future scope addition is an intentional decision, not an accident. MigrationRollbackTest (group 'slow'): full migrate:fresh → rollback 14 S1 steps → assert all 13 form-builder tables dropped + legacy tables intentionally retained → re-migrate and assert table list matches snapshot. Plus a separate test exercising the populate-user-profiles migration's down(). Supporting tweaks: - UserProfile::lastSubmittedAt accessor now returns Carbon|null instead of a raw timestamp string — testable, and matches Eloquent convention. - UserProfileFactory cooperates with UserObserver via newModel override (updates the auto-created row instead of inserting a duplicate). - AppServiceProvider morph map extended with all 12 form-builder model keys so logSchemaChange/logFieldChange resolve under enforceMorphMap. Suite: 945 passed (was 911), 2671 assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |