Commit Graph

10 Commits

Author SHA1 Message Date
a71201f4d3 feat(form-builder): add pre-publish binding check per purpose
`FormSchemaService::publish()` now verifies that every binding path
declared by the schema's PurposeDefinition::requiredBindings is present
on at least one of the schema's `form_fields.binding` JSON entries.
Missing bindings raise PurposeRequirementsNotMetException with a
structured `purposeSlug` + `missingBindings[]` payload.

v1.0 this is a trivial JSON scan; in WS-5a the check will switch to
the relational `form_field_bindings` table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:35:56 +02:00
ab67ed46ca refactor(form-builder): consolidate subject-type allow-list into purpose registry
Q6 of ARCH-CONSOLIDATION-ADDENDUM-2026-04-24: the allowed
`form_submissions.subject_type` values are now derived from
`PurposeRegistry::allSubjectTypes()` instead of the parallel
`config/form_subjects.php` file.

- CreateFormSubmissionRequest validates `subject_type` against the
  registry via constructor-injected PurposeRegistry.
- FormSubmissionController and FormValueService resolve the subject
  FQCN through `Relation::getMorphedModel()` — the morph-map is the
  single source of truth for alias → model mapping.
- `config/form_subjects.php` is deleted. `MorphMapAlignmentTest` keeps
  the registry and morph-map aligned going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:35:48 +02:00
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>
2026-04-23 22:26:58 +02:00
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>
2026-04-23 18:54:58 +02:00
6ba921442c fix(form-builder): explicit OrganisationScope bypass on every public-form query
Five models that the public form endpoints touch carry a global
OrganisationScope: FormSchema, Event, TimeSlot, FestivalSection,
PersonTag. The initial S2c implementation relied on the scope no-opping
because /public/forms/* has no `{organisation}` route parameter and
OrganisationScope::resolveOrganisationId returns null in that case.

That's accidentally-correct. Any middleware that sets an implicit org
context later (route model binding for platform admin, impersonation,
default-org fallback on an authed Sanctum session) would start
filtering public schema resolution by the wrong org.

- PublicFormTokenResolver: both FormSchema::query() calls now pass
  withoutGlobalScope(OrganisationScope::class). public_token is
  globally unique so this is safe.
- PublicFormController::timeSlots() / sections() / festivalEventIds():
  Event, TimeSlot, FestivalSection queries all explicit now, including
  the eager-loaded event relation on time-slots.
- PublicFormController::ownerEvent(): narrowed from
  Event::withoutGlobalScopes() to withoutGlobalScope(OrganisationScope)
  so future scopes (soft-delete, archived) aren't accidentally
  stripped.
- PublicFormSchemaResource::availableTagsByCategory: same narrowing on
  the PersonTag query.

PublicFormCrossOrgScopeTest pins the expectation — 4 cases hit every
public endpoint under a stashed foreign-org route parameter and assert
the owner-org data still surfaces. Verified the tests fail when the
fix is reverted (all 4 return `SCHEMA_NOT_FOUND` with the bypass
absent).

Full suite 893 → 897 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:16:22 +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
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>
2026-04-17 22:56:20 +02:00
79d834cb1d feat(seeder): dev event_registration schema with draft + submitted submissions exercising FORM-02 (§31.10)
Sprint 0.5. Extends FormBuilderDevSeeder (additive) so that after
`migrate:fresh --seed` the dev org has:

- one published public-token-enabled event_registration schema anchored
  to the primary festival (Echt Feesten 2026) with a curated 5-field
  set (HEADING / SELECT / CHECKBOX_LIST / TAG_PICKER / TEXTAREA) —
  mirrors the subset Bert needs to eyeball via the portal and verify
  §31.10 sync with;
- one draft submission (partial fill: shirtmaat + dieetwensen) for the
  first approved person with user_id — the TAG_PICKER is deliberately
  absent so this submission does NOT fire the listener;
- one submitted submission for the next approved person, with
  TAG_PICKER values = the first 3 active person_tags by sort_order.
  The submission is pushed through FormSubmissionService::submit so
  FormSubmissionSubmitted fires, SyncTagPickerSelectionsOnSubmit runs,
  and user_organisation_tags receives 3 self_reported rows.

Queue-connection contract: production runs QUEUE_CONNECTION=redis, so
the listener would queue and not execute before the seeder returns.
The seeder temporarily flips queue.default to sync for the submit()
call so Bert sees the synced tags immediately after `--seed`.

Console output matches the Sprint 0.5 spec: public URL for GET-testing
+ a line naming the submitter and the sync result count.

Wired from DevSeeder::seedEchtFeesten() behind an
app()->environment('local', 'testing', 'development') guard (belt-and-
suspenders on top of DatabaseSeeder's existing local gate).

Collateral fix: FormSubmissionService::submit() stored signed fractional
seconds into the unsigned `submission_duration_seconds` column. Carbon
3's diffInSeconds returns signed floats when `opened_at` is earlier than
now, which MySQL rejects. Wrapped with abs() + int cast. No test
expectations relied on the sign so 857 tests remain green.

Verified via tinker after `migrate:fresh --seed`:
  fields_count = 5, submissions_count = 2 (1 draft + 1 submitted),
  values on submitted = 4, self_reported tags for submitter = 3,
  PublicFormSchemaResource returns all 5 fields on the public token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:58:42 +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
b3eab6e0c8 feat(form-builder): add core services (schema, field, submission, value, field-access, locale, tag-sync, filter, webhook, anonymisation)
S2b Phase 1 per ARCH-FORM-BUILDER.md §20.2. Ten services + supporting
exceptions, jobs, and the organisations.default_locale column needed by
FormLocaleResolver. All services log via spatie/laravel-activitylog, write
operations are transactional, queued jobs are idempotent.

- FormSchemaService: CRUD, slug, version bump, duplicate, edit-lock,
  public_token rotation (7-day grace window), typed-confirmation delete.
- FormFieldService: CRUD, reorder, insertFromLibrary, binding-change guard
  (§6.5), conditional_logic + section cycle detection (§8, §4.8.1),
  is_filterable toggle triggers BackfillFormValueIndexedJob (§7.2, §22.10).
- FormSubmissionService: createDraft with idempotency, saveDraft (auto-save),
  submit with schema snapshot + signature hash computation (§9), review,
  delegate/revoke, soft delete. Fires S1 domain events (§17.1).
- FormValueService: bulk upsert with FieldAccessService RBAC (§24.2),
  Pattern A/C entity mirror writes (§6.1, §6.6) with cross-entity graceful
  skip for person.user_id=null.
- FieldAccessService: canRead/canWrite/filterVisibleFields honouring
  role_restrictions + subject-self (§18.3, §24.1).
- FormLocaleResolver: submitter → schema → org.default_locale → 'nl' (§16.2).
- FormTagSyncService: rebuildForPerson — replaces legacy TagSyncService
  deleted in S2a (§31.10).
- FilterQueryBuilder: generic filter applier for entity_column / tags /
  form_field sources (§7.4–§7.5).
- FormWebhookDispatcher + DeliverFormWebhookJob: HMAC-signed delivery with
  SSRF protection, exponential backoff {1m,5m,30m,2h,8h}, max 5 attempts,
  dead-letter on exhaustion (§17.5).
- FormSubmissionAnonymisationService: per-field anonymisation with separate
  activity log entries (§13.3, §23.4).

MigrationRollbackTest: pin the S2a drop migration by filename so future
migrations don't shift the step offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:47:39 +02:00