Commit Graph

273 Commits

Author SHA1 Message Date
2b16d4fd8f docs(policy): add Form Builder scope discipline policy
Deferred-work guidance for Form Builder expansions. Prevents
speculative building for hypothetical use cases while preserving
the one justified forward-looking investment (polymorphic subject
picker in S3b). Based on April 2026 coverage analysis of ~60
practical form use cases.
2026-04-24 10:18:44 +02:00
7df37b8823 feat(form-builder): form schema types and TanStack Vue Query composables
Adds apps/app/src/types/formSchema.ts with FormSchema, FormSchemaSummary,
FormSchemaPurpose, FormSubmissionMode, FormSchemaSnapshotMode, and the
payload/response shapes for schema CRUD plus lifecycle operations
(publish, unpublish, duplicate, rotate-public-token).

Adds apps/app/src/composables/api/useFormSchemas.ts mirroring the
useSections pattern: useFormSchemaList, useFormSchema, plus seven
mutations covering CRUD, duplicate, publish/unpublish and public-token
rotation. All queries and mutations invalidate the right cache keys.

Fields and sections on the full FormSchema are typed as unknown[] with
a TODO pointing to PR-b3 when the organizer field types land. No UI,
routes, or navigation — those come in PR-b2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:52:44 +02:00
214a2debee refactor(form-schema): inline validators to remove @core transitive dep
Resolves TECH-07. Copies the four validators actually used
(requiredValidator, emailValidator, urlValidator, regexValidator) from
@core/utils/validators into packages/form-schema/src/utils/validators.ts
as pure boolean functions. Vuexy template copies in apps/*/src/@core/
remain for non-form UI use. Package is now genuinely standalone —
grep -rn "@core/" packages/form-schema/ returns zero matches.

Also corrects two documentation inconsistencies from commit 42dd626e:
dev-guide heading translated to Dutch for style consistency, and the
BACKLOG entry renumbered from TECH-DEBT-01 to TECH-07 to match the
flat numbering in the Technische schuld section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:20:15 +02:00
42dd626e37 docs(form-schema): document shared package boundary and tech debt
Documents the "share schema, not UI" principle in dev-guide.md so the
boundary stays intact in future work. Logs TECH-DEBT-01 for the
@core/utils/validators transitive dependency discovered during PR-a.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:04:41 +02:00
dda60ed5e4 refactor(form-schema): extract schema types and schema-driven behaviors to shared package
Moves formBuilder types, formValidation, useConditionalLogic, useFormSteps,
and formatFieldValue from apps/portal/src to packages/form-schema/src.
Adds @form-schema path alias to both apps/portal and apps/app.
Vue field components remain per-app to allow independent visual evolution.
Behavior-neutral: all 35 Vitest tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:57:39 +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
e95f9a75f6 fix(portal): review display, hover overlay, and drag ghost for complex field types
- Extract formatFieldValue helper for shared use between review step
  and confirmation page — one source of truth for TAG_PICKER,
  AVAILABILITY_PICKER, and SECTION_PRIORITY display, so the raw-ID
  and [object Object] leaks from two parallel stringifiers can't
  regress on either side.
- TAG_PICKER: lookup via field.available_tags (server-inlined).
- AVAILABILITY_PICKER: lookup via usePublicFormTimeSlots, strip
  seconds. "Laden…" while the cache warms.
- SECTION_PRIORITY: defensive shape-guard prevents [object Object]
  leaks, sorted priority-prefixed rendering ("1. Bar, 2. Hospitality").
- Subtle primary-tinted hover (4% primary, primary border) replacing
  the near-black Vuetify default overlay on unranked section cards.
- Explicit ghost-class / drag-class / chosen-class on vuedraggable
  with solid drag-clone + elevation shadow and a 30%-opacity silhouette
  at the origin, so mid-drag text no longer overlaps.
- 17 new formatFieldValue unit assertions + 2 new FieldSectionPriority
  assertions locking in the draggable classes and the disabled-card
  toggle at max.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:12:47 +02:00
6f032a0311 docs(backlog): move FORM-09 to resolved — listener refactored in previous commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:19:00 +02:00
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>
2026-04-23 21:18:08 +02:00
1875e79ce1 docs(backlog): add gaps discovered during S3a PR 2
- TECH-06 — ESLint config ontbreekt in apps/portal (pendant van TECH-05).
- DOC-02 — VitePress docs:build faalt op missing image in
  /docs/volunteer/je-aanmelden-via-een-link.md.
- DOC-03 — Formulieren sidebar story is incompleet; nog geen
  publicatieflow, inzendingen-overzicht, templates, webhooks,
  conditionele logica.
- FORM-09 — TriggerPersonIdentityMatchOnFormSubmit ShouldQueue
  herzien: async queue-dispatch levert null bij submit-response;
  eager state + lazy resolution patroon invoeren nu de refactor
  nog klein is (voor FORM-05 landt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:44:46 +02:00
a97922d6a4 docs(form-builder): document S3a PR 2 complex field types and update FORM-05 stub note
- Add VitePress pages for AVAILABILITY_PICKER and SECTION_PRIORITY
  and a TAG_PICKER configuration note. Wire them into the organisator
  sidebar under a new Formulieren section alongside the existing
  "Wat is een formulier" page.
- BACKLOG.md: nuance FORM-05 — the stub-shaped behaviour for public
  event_registration submissions is already shipping via the existing
  TriggerPersonIdentityMatchOnFormSubmit listener (writes 'pending').
  The real work (PersonIdentityService::detectMatchesByValues + an
  extra branch in resolveStatus) is what remains. Added a done entry
  for S3a PR 2 to the Opgeloste items list.
- API.md: add VALIDATION_FAILED to the public-form error code table
  and document the SECTION_PRIORITY shape error messages (Dutch copy
  served under errors."values.{slug}").
- COPY_CATALOGUE.md: new S3a PR 2 section capturing the seeder
  help_text, the IdentityMatchBanner copy (clearly marking the
  backend message as authoritative), all empty/error state copy for
  the three new components, and the SECTION_PRIORITY shape error
  strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:34:34 +02:00
9256c05db0 feat(portal): implement TAG_PICKER, AVAILABILITY_PICKER, SECTION_PRIORITY field types
- FieldTagPicker: VAutocomplete multiple with grouped category slots,
  empty/null category normalised to "Overig", empty-state info alert
  when the server delivers no tags.
- FieldAvailabilityPicker: date-grouped checkbox list, festival-aware
  via usePublicFormTimeSlots. Event-name subheaders only surface when
  the time-slots span multiple events. Time format strips seconds.
- FieldSectionPriority: tap-to-rank + drag-to-reorder via vuedraggable
  for desktop; mobile tap-only. Renumbers priorities on every mutation.
  Self-heals malformed modelValue. UI soft cap via
  validation_rules.max_priorities clamped to the backend hard cap of 5.
- FieldRenderer: three new types removed from isStubbed.
- publicFormInjection: page-level provide/inject for the public token.
- IdentityMatchBanner: prefers backend-provided Dutch copy with
  frontend defaults as defensive fallback.
- FormConfirmation wires the banner inline.
- usePublicFormTimeSlots and usePublicFormSections TanStack composables.
- 40 new Vitest assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:00:40 +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
d274284fd4 docs: add CLAUDE_DESKTOP_SETUP.md describing Gitea MCP context strategy 2026-04-23 17:27:08 +02:00
333db5818d chore: add local deploy wrapper example and ignore personal copy
Ship an example run-deploy-from-local.sh with a placeholder SSH alias
so each developer can drop in their own; the real file stays gitignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:22:48 +02:00
ddef06eac8 chore: add production deploy script
Adds deploy.sh for the Crewli VPS: locks against concurrent deploys,
conditionally runs composer/npm install, builds both SPAs, migrates,
rebuilds caches, and health-checks the API before going live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:22:45 +02:00
d67502eaec docs(api): refresh Form Builder public API surface notes
Updates API.md and S3a discovery doc to reflect submitter-details
handling and the draft/submit split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:15 +02:00
fe70a4d242 docs(volunteer): add 'aanmelden via een link' page
Explains the public-link registration flow for vrijwilligers and wires
the page into the VitePress sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:11 +02:00
b159fdcc26 test(portal): add Vitest setup and public-form tests
Introduces vitest config, jsdom setup, and first suites covering
FieldRenderer dispatch and useConditionalLogic evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:04 +02:00
4074dce402 feat(portal): public-form component architecture
Replace monolithic register/[eventSlug].vue with composable field
renderer, conditional-logic engine, stepper, and per-field components
driven by Form Builder schema. Adds flatpickr for date fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:20:59 +02:00
0cbdad70cd fix(api): accept submitter details on public draft PUT and submit POST
S3a PR 1 frontend sends public_submitter_name and public_submitter_email
on draft saves (PUT) and final submit (POST /submit), but the matching
SavePublicDraftRequest and SubmitPublicSubmissionRequest did not whitelist
these fields — Laravel's validated() silently stripped them, preventing
mid-form name/email updates from persisting.

Align both form requests with StartPublicDraftRequest to accept the same
submitter fields with identical rules (string, max:150 / email, max:255,
nullable). Controller copies present keys onto the submission model and
saves when dirty, matching standard Laravel update() semantics — missing
keys leave prior values untouched.

Closes the backend gap identified in PR 1 smoke test.
2026-04-23 16:36:31 +02:00
71be107c54 test(portal): cover submitter details in useFormDraft
Adds nine cases against useFormDraft's submitter surface. The S3a PR 1
smoke test found that submitter name/email were never sent to the
backend — a proper test would have caught that.

Covers: initial empty state, setter dirty-tracking flowing into the PUT
body, both name and email in the POST /submit body, the
MISSING_SUBMITTER guard when either field is empty (no endpoint call),
sessionStorage resume populating state and the initial start POST, and
session cleanup after successful submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:09:05 +02:00
f5f3c99fb1 feat(portal): sentence-case button labels on public register page
Vuexy's Vuetify preset capitalizes VBtn labels, so "Sla op als concept"
rendered as "Sla Op Als Concept". Dutch convention on this page is
sentence-case. Adds a scoped utility class applied to the four VBtns on
the public register page (Vorige / Sla op als concept / Volgende /
Verstuur) rather than touching the global Vuetify defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:08:56 +02:00
3ecd4daee1 feat(portal): persist submitter details through draft lifecycle
Adds submitterName/submitterEmail state and setters to useFormDraft and
wires them through start/saveDraft/submit. Previously the Contactgegevens
name/email were held in a local page ref and never made it into any
request body, so submissions landed in the DB with NULL submitter fields
and a mid-form reload wiped whatever the user had typed.

- useFormDraft: internal submitterName/submitterEmail refs with setters
  that mark the draft dirty (same debounced-PUT path as field values),
  sessionStorage resume via draft_submitter:{token}, and a
  MISSING_SUBMITTER guard in submitForm so empty fields surface as
  submitError without hitting the endpoint.
- register/[public_token].vue: deletes the local submitter refs and
  reads/writes through the composable; onSubmit pre-validates and
  bounces the user back to the Contactgegevens step with a snackbar
  when fields are missing.
- SaveDraftBody / SubmitBody: optional public_submitter_name and
  public_submitter_email per the documented backend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:08:13 +02:00
102b6006fa docs(backlog): add FORM-05 smart identity-match on public submission values 2026-04-17 23:18:45 +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
68d2c830a0 docs(form-builder): API.md Form Builder (Public), SCHEMA v2.1, ARCH §10.4, BACKLOG
S2c Phase 8.

- API.md: new **Form Builder (Public)** section documenting all 6
  public endpoints (GET schema + time-slots + sections; POST draft,
  PUT save, POST submit) with request/response examples, error codes,
  and the identity_match / schema_drift contracts. No PII-echo noted
  explicitly.
- SCHEMA.md bumped to v2.1:
  - changelog entry for S2c.
  - form_submissions table gains schema_version_at_open +
    identity_match_status columns; UNIQUE (form_schema_id,
    idempotency_key) replaces the composite index; a new composite
    index (form_schema_id, identity_match_status) landed for the
    organiser "pending-match" dashboard.
- ARCH-FORM-BUILDER.md bumped to v1.3 with new §10.4 "Public
  submission lifecycle — draft/save/submit split" documenting the
  three-endpoint contract, idempotency, schema-drift detection,
  access rules, the standardised error envelope, and the dependency
  data sub-endpoints.
- BACKLOG.md adds:
  - FORM-04 (grace_days configurable — current implementation still
    uses the hard-coded 7-day window)
  - DOC-01 (Scramble / OpenAPI generator for API.md to reduce the
    docs-drift effort going forward).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:07:26 +02:00
9b1bf0e13d test(form-builder): public form API — 36 new tests covering S2c deliverables
Eight new feature test files under tests/Feature/Api/V1/Public/FormBuilder/.
Full suite 857 → 893 green.

- PublicFormSchemaResourceTest (3) — TAG_PICKER available_tags grouped
  by category, tag_categories filter, version + opened_at top-level.
- PublicFormTimeSlotsTest (4) — volunteer-only filter, festival
  children included, 410 TOKEN_EXPIRED on rotated-past-grace, 404
  SCHEMA_NOT_FOUND on unknown token.
- PublicFormSectionsTest (2) — show_in_registration + type=standard
  filter, dedup-by-name across festival children.
- PublicFormDraftLifecycleTest (8) — POST creates draft (201),
  idempotent replay returns 200 w/ same id, idempotency_key required,
  PUT partial update increments auto_save_count, submit happy path,
  409 SUBMISSION_ALREADY_SUBMITTED on re-submit, schema_drift flagged
  when organiser edits mid-draft, 404 when submission_id belongs to
  another schema.
- PublicFormValidationTest (6) — EMAIL format, NUMBER type, SELECT
  option list, NUMBER min/max from validation_rules, required-at-submit
  enforcement, draft-save tolerates missing required.
- PublicFormSubmissionResourceTest (3) — no PII echo
  (public_submitter_name/email/ip suppressed), admin metadata
  (review_status/schema_snapshot/reviewed_by) absent, identity_match
  shape with Dutch message on pending.
- PublicFormErrorEnvelopeTest (5) — SCHEMA_NOT_FOUND, TOKEN_EXPIRED,
  SCHEMA_UNPUBLISHED codes; 422 VALIDATION_FAILED carries errors; 429
  RATE_LIMITED carries Retry-After header.
- IdentityMatchOnSubmitTest (5) — event_registration triggers
  matched/none/pending per person state; non-event_registration purpose
  does not trigger; public-subject submissions write pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:03:28 +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
e4294702c5 feat(form-builder): public resources — TAG_PICKER tags, schema version, submission shape
S2c D1, D5, D7.

PublicFormSchemaResource:
- Carries available_tags on every TAG_PICKER field, respecting
  validation_rules.tag_categories when set. Tags prefetched once per
  org to avoid N+1.
- Surfaces the schema version and an opened_at timestamp so the portal
  can detect drift between GET-time and submit-time.

PublicFormSubmissionResource (new): the submission envelope the portal
sees on every lifecycle endpoint. No PII echo — public_submitter_name,
public_submitter_email, public_submitter_ip never appear. Admin
metadata (review_status, reviewed_by, schema_snapshot, submitter user
id) is filtered out. Includes schema_drift computed from
schema.version vs schema_version_at_submit, and identity_match read
from the §31.1 listener's column with a Dutch message per state
(pending|matched|none).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:55:54 +02:00
53fe4d25a7 feat(form-builder): standardised error envelope for public form API (D6)
S2c D6. Seven concrete exceptions over a shared PublicFormApiException
base + a single renderer in bootstrap/app.php produce the contract:

  { "message": "...", "code": "...", "errors"?: {...} }

Codes: SCHEMA_NOT_FOUND (404), TOKEN_EXPIRED (410), TOKEN_REVOKED (410),
SCHEMA_UNPUBLISHED (410), SUBMISSION_ALREADY_SUBMITTED (409),
RATE_LIMITED (429 with Retry-After header), VALIDATION_FAILED (422
with per-field errors).

Used by PublicFormController (resolve) and PublicFormSubmissionController
(load/submit lifecycle). Every public-form endpoint now emits the same
envelope regardless of which branch failed; the renderer only fires on
PublicFormApiException so the authenticated API still uses its default
Laravel shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:55:44 +02:00
a3f35e533f feat(form-builder): identity-match listener + identity_match_status column
S2c D9. Implements ARCH §31.1 — identity matching triggered on
FormSubmissionSubmitted for event_registration schemas.

- Migration 2026_04_22_100000: add form_submissions.identity_match_status
  (nullable string(20), pending|matched|none) + index
  (form_schema_id, identity_match_status).
- Migration 2026_04_22_100001: replace the composite index on
  (form_schema_id, idempotency_key) with a UNIQUE constraint so the DB
  itself is the race-safe backstop behind the application-level
  idempotency replay.
- Listener TriggerPersonIdentityMatchOnFormSubmit: runs only when
  form_schema.purpose === event_registration. For person-subject
  submissions it calls PersonIdentityService::detectMatches and writes
  matched/pending/none; for public (subject=null) it records 'pending'
  so the portal can message the submitter that matching will complete
  when the organiser attaches a person. Failures log at error level
  and never rethrow — sibling listeners on the same event (§31.10
  TAG_PICKER sync) still run.
- AppServiceProvider wires the listener alongside
  SyncTagPickerSelectionsOnSubmit.
- FormSubmission.$fillable gains identity_match_status.

Rationale for a dedicated column (over JSON on submission.metadata):
the matrix is a hard-typed 3-state enum that the public API surfaces
directly, and we want to index it to show organiser dashboards "how
many submissions are pending identity-confirmation".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:55:35 +02:00
8dd874916f docs(discovery): S3a public Form Builder API surface report
Carried over from the prior discovery session. Lists the 12 gaps (4 hard
blockers, 8 soft) that S2c closes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:55:16 +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
a51f3d3a47 docs(schema): bump v1.8 → v2.0 with Form Builder tables, drop legacy registration EAV
Reflects the post-S1+S2a+S2b database state. Nothing but SCHEMA.md changed.

- Header: Version → 2.0, added v2.0 changelog entry covering the 13 new
  tables, the 3 dropped legacy tables, the preserved
  person_section_preferences, organisations.default_locale, and the
  events.registration_show_* drops.
- Table of Contents: updated §3.5.5b name to "Section Preferences",
  added entries for §3.5.10 Email Infrastructure, §3.5.11 Rules,
  §3.5.12 Form Builder (which were already in the file but missing
  from the TOC).
- §3.5.1 organisations: added default_locale column (FormLocaleResolver
  fallback chain, ARCH §16.2).
- §3.5.1 events: removed registration_show_section_preferences +
  registration_show_availability columns with a pointer at
  form_fields.is_portal_visible / conditional_logic.
- §3.5.4: removed the never-created volunteer_profiles table block;
  the other three tables in that section (volunteer_festival_history,
  post_festival_evaluations, festival_retrospectives) are unchanged.
- §3.5.5b: renamed to "Section Preferences"; design note pointing at
  events.registration_show_section_preferences replaced with a pointer
  at form_fields.is_portal_visible / conditional_logic.
- §3.5.9: renamed to "Check-In & Operational"; removed the never-created
  public_forms stub and the colliding legacy form_submissions block
  (both documented planned-but-never-created tables) with a short note
  pointing at the Form Builder as the home for form concepts. Flagged
  separately below because it's technically beyond the task's explicit
  scope but unavoidable (SCHEMA.md would otherwise describe two
  different tables under the same name).
- §3.5.12 Form Builder: summary replaced with full per-table
  documentation for all 13 tables in the ARCH §4 order — user_profiles,
  form_schemas (polymorphic owner, public_token rotation with
  public_token_previous + public_token_rotated_at, edit_lock_*),
  form_schema_sections, form_field_library, form_fields, form_submissions,
  form_submission_section_statuses, form_submission_delegations,
  form_values (observer-driven typed columns value_indexed/number/date/bool
  and form_value_options multi-value rebuild per ARCH §7.2),
  form_value_options, form_templates, form_schema_webhooks,
  form_webhook_deliveries. Added short notes on activity log strategy
  and the §31.10 FORM-02 tag-sync listener.

Migrations-vs-ARCH discrepancies (migrations win, per CLAUDE.md):

- form_values carries created_at / updated_at timestamps, though ARCH §4.4
  does not list them. Documented as present.
- form_webhook_deliveries has no timestamps columns; last_attempt_at is
  the effective timestamp. Documented as such.
- form_schema_webhooks stores url / secret as encrypted TEXT columns
  (Eloquent-cast encryption); ARCH says "encrypted" without specifying.
  Documented the column type.
- public_forms + legacy form_submissions documented in §3.5.9 never
  existed in the DB (confirmed via Schema::hasTable). Removed those
  doc stubs; the naming collision with the new Form Builder
  form_submissions made leaving them in place a correctness hazard.
2026-04-17 21:48:57 +02:00
2d6d2b2991 docs(form-builder): API.md, ARCH §31.10, BACKLOG
Phase 7 of S2b.

- API.md: "Form Builder" section rewritten with every new route
  (schemas / fields / submissions / values / delegations / templates /
  field library / webhooks / filter registry / public token flow).
  Calls out §22.8 typed-confirmation deletes, §6.5 binding-change guard,
  §9 signature hash on submit, §7.4–§7.5 FilterQueryBuilder contract,
  and that FormSubmissionSubmitted is the trigger for the §31.10
  TAG_PICKER sync listener.
- BACKLOG.md: FORM-02 marked done with the shipped artefacts and the
  deferred §31.9 contract tests spelled out.
- ARCH-FORM-BUILDER.md §31.10 already rewrote authoritatively in Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:28:54 +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
65070faf47 feat(form-builder): controllers and routes (auth + public token)
Phase 5 of S2b. Ten thin controllers plus route registration under the
existing organisations/{organisation} prefix and two unauthenticated
public endpoints.

Controllers (api/app/Http/Controllers/Api/V1/FormBuilder/):
- FormSchemaController: CRUD + duplicate/publish/unpublish/rotate-token/
  edit-lock. Returns 410 via PublicFormController when a rotated token is
  past its 7-day grace window.
- FormFieldController: CRUD + reorder + insert-from-library. 422 on
  binding-change / frozen / cyclic conditional_logic.
- FormSubmissionController: index/store/show/submit/destroy.
- FormValueController: bulk upsert draft values; 403 when
  FieldAccessService rejects a write.
- FormSubmissionReviewController, FormSubmissionDelegationController.
- FormTemplateController, FormFieldLibraryController (deactivate on
  DELETE for is_active records).
- FormSchemaWebhookController (url/secret never leak — only url_host +
  has_secret in responses).
- FilterRegistryController: cached entity_column + tags + form_field
  source list for Personen-module (ARCH §7.3–§7.5).
- PublicFormController: GET schema + POST submission. Turnstile captcha
  for public_complaint/public_press_request. Rate-limited per
  IP+public_token. 410 when token expired.

Routes: grouped under organisations/{organisation}/forms/ for auth'd
routes and public/forms/{public_token}/... with throttle:30,1 for the
public pair. Policies auto-discovered from the namespaced location.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:18:06 +02:00
4b7e66b83f feat(form-builder): API resources with FieldAccessService filtering
Phase 4 of S2b. Nine resources that shape the universal form builder
responses. FieldAccessService::filterVisibleFields gates every field
array — the primary defence tested by FormResourceSecurityTest (§22.9).

- FormSchemaResource: includes fields_count, submissions_count,
  has_submissions, is_locked (derived from edit_lock_*), public_form_url
  when public_token is set, and filtered fields collection.
- FormSchemaSummaryResource: lean list-endpoint variant.
- FormFieldResource: effective_label / help_text / options resolved via
  FormLocaleResolver + translations JSON, plus TAG_PICKER available_tags
  filtered by validation_rules.tag_categories.
- FormSubmissionResource: values keyed by field slug with FieldAccessService
  filtering, section_statuses, active delegations, review_info,
  submitted_in_locale, submission_duration_seconds.
- FormSubmissionSummaryResource: lean list variant.
- FormTemplateResource, FormFieldLibraryResource.
- PublicFormSchemaResource: strictly limited per §10 — only
  is_portal_visible=true AND is_admin_only=false fields, no PII hints,
  no role_restrictions, no submissions_count.
- FormSchemaWebhookResource: url/secret never returned; only url_host +
  has_secret boolean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:13:40 +02:00
ab84850089 feat(form-builder): policies and form requests with scoped exists rules
Phase 3 of S2b. Six policies and fifteen form requests for the universal
form builder. Every exists: rule is scoped to the route's organisation
or form_schema to close the A01-5..18 findings from SECURITY_AUDIT.md.

Policies (api/app/Policies/FormBuilder/):
- FormSchemaPolicy, FormFieldPolicy, FormFieldLibraryPolicy,
  FormTemplatePolicy, FormSubmissionPolicy, FormSchemaWebhookPolicy.
- FormSubmissionPolicy honours subject-self (user / person.user_id
  match / submitted_by_user_id) and active delegations, per §18.3.
- No `return true` placeholders — each method checks org membership and
  role via Spatie's hasRole().

Form Requests (api/app/Http/Requests/Api/V1/FormBuilder/):
- Schema: Store/UpdateFormSchemaRequest, RotatePublicTokenRequest.
- Fields: Store/UpdateFormFieldRequest, ReorderFormFieldsRequest (field
  ids scoped to the route schema), InsertLibraryFieldRequest (library
  scoped to the route organisation).
- Templates: Store/UpdateFormTemplateRequest.
- Field library: Store/UpdateFormFieldLibraryRequest.
- Submissions: CreateFormSubmissionRequest, UpsertFormValuesRequest
  (slug allow-list derived from schema), SubmitFormSubmissionRequest,
  ReviewFormSubmissionRequest, DelegateFormSubmissionRequest (delegatee
  scoped to organisation pivot).
- Webhooks: Store/UpdateFormSchemaWebhookRequest.
- Public: PublicSubmissionRequest (captcha_token collected here,
  enforcement in controller per config('form_builder.captcha')).

All enum validation routes through the existing PHP enums from S1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:08:49 +02:00
4495ab017e feat(form-builder): FORM-02 TAG_PICKER sync listener (ARCH §31.10)
Rebuilds the tag-sync flow purged in S2a, now listener-driven against the
universal FormBuilder (ARCH §31.10).

- SyncTagPickerSelectionsOnSubmit listener: ShouldQueue on connection=redis
  queue=default. Filters to event_registration + person subjects with at
  least one TAG_PICKER form_value. Logs on failure, never rethrows so
  sibling listeners keep running.
- AppServiceProvider registers the listener via Event::listen alongside
  the existing S1 observers.
- PersonIdentityService::confirmMatch now calls
  FormTagSyncService::rebuildForPerson after setting person.user_id — the
  deferred-sync path for persons who filled in TAG_PICKER fields before
  their account was linked.
- ARCH-FORM-BUILDER.md §31.10 rewritten with the authoritative contract
  block from this session. Header bumped to v1.2.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:00:17 +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
a3ca596362 S2a: purge legacy Form Builder PHP code and routes 2026-04-17 18:43:00 +02:00
cfc7610497 docs(forms): SCHEMA crosswalk, foundation concept page, getting-started + migration playbook, copy catalogue init
SCHEMA.md
- New §3.5.12 "Form Builder" with the legacy-tables-retained note
  placed prominently directly under the section header (per S1 wrap-up
  Path 3 decision: Phase 8 deferred to S2).
- Crosswalk: every legacy volunteer_profiles column → its new home
  (user_profiles columns vs form_fields vs person_tags).
- Summary table for the 13 new tables with one-line purpose + ARCH §
  pointer each.
- Activity log strategy and multi-tenancy discipline noted.
- §3.5.4 marked SUPERSEDED with a pointer to the new section.

/dev-docs/form-builder-migration-playbook.md (new)
- Operator runbook for forms:migrate-legacy-data on real legacy data.
- Pre-flight audit, dry-run, migrate, verify, spot-check, rollback
  paths spelled out. Same legacy-tables-retained note prominently.

/dev-docs/form-builder-getting-started.md (new)
- Developer onboarding. Mental model, code samples for creating a
  schema/field/submission/value, adding a new subject type, registering
  a custom field type, suppressing activity log via
  App\Support\ActivityLog::suppressed.

/dev-docs/COPY_CATALOGUE.md (new)
- Seeded verbatim from ARCH §30 (naming conventions, tooltip catalogue,
  warning catalogue) with a header explaining purpose, growth strategy,
  and the per-PR update workflow.

/docs/organizer/forms/concepts/wat-is-een-formulier.md (new VitePress)
- Dutch, informal je/jij. Follows /docs/.templates/concept-page.md.
- Three example use-cases: vrijwilligersregistratie, artist advance,
  incidentrapportage. Light foundation; depth arrives in S2-S5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:06:53 +02:00
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>
2026-04-17 16:44:47 +02:00
ccdfd5b77b fix(forms): gate value_indexed population on is_filterable
FormValueObserver: value_indexed is filter-driven per ARCH §4.4, not
hint-driven. Populating it for every string-hint field produced dead
weight in the partial index and made FilterQueryBuilder logic murkier.

Behaviour after fix:
  hint=string,  is_filterable=true  → populate value_indexed
  hint=string,  is_filterable=false → leave null
  hint=number/date/bool, any filterable → populate typed column (unchanged)
  hint=json, any filterable → leave typed columns null (unchanged)

value_number / value_date / value_bool remain hint-driven — they serve
display and sorting beyond filtering. Only value_indexed is gated.

VerifyFormsDataIntegrity: "value_indexed set on non-filterable field"
is now a FAIL (was WARN) — it means the observer didn't run correctly,
which is a real integrity issue.

Observer tests: split the old "string hint populates value_indexed"
case into filterable/non-filterable pair. Full suite 911/911.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:28:15 +02:00
021a3cd079 refactor(seeders): move DevSeeder to new form-builder structure
Adds UserObserver::created() that firstOrCreate's a user_profiles row
for every User. Registered in AppServiceProvider alongside PersonObserver.
Covers DevSeeder (3 scattered User::create sites: DatabaseSeeder super admin,
DevSeeder org staff, DevSeeder volunteer users) and all future creation
paths (invite/register/import) with zero per-caller boilerplate.

New FormBuilderDevSeeder seeder class holds canonical 16-field registration
template (borrowed from the legacy RegistrationFieldTemplateService list so
test data stays recognisable). Produces per-org:
- 16 form_templates (system, schema_snapshot per ARCH §4.6.1)
- 1 FormSchema per event (event_registration, owner=event, draft_single
  mode, is_published mirrors event.status lifecycle)
- 16 FormFields per schema
- 1 FormSubmission per person whose status ∈ applied/approved/no_show
  (same rule as MigrateLegacyFormsData), with 6 realistic FormValues each

DevSeeder::run() now wraps the whole seed body in
ActivityLog::suppressed(...) so the ~80 field creates + ~277 submission
lifecycle triggers don't flood activity_log. Also removes the legacy
RegistrationFieldTemplateService::seedSystemTemplates call — the 16
system templates now land directly in form_templates.

Post-seed totals (dev DB):
  5 form_schemas, 80 form_fields, 277 form_submissions, 1662 form_values,
  16 form_templates, 270 user_profiles (1:1 with users).

forms:verify-data-integrity on freshly seeded DB: exit 0.
php artisan test: 910/910.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:08:43 +02:00
72892d38f4 feat(forms): add data migration and verification commands
forms:migrate-legacy-data {--dry-run} {--verify-only}
  Per-org transaction (outer loop); inside each org, one form_schema per
  distinct event_id in registration_form_fields, one form_field per legacy
  field (with lowercase→uppercase field_type mapping and PII heuristic),
  one form_submission per distinct person_field_values author, one form_value
  per legacy row. form_templates derive schema_snapshot in ARCH §4.6.1 shape.
  Idempotent via existence checks; skips if registration_form_fields absent.
  Wrapped in App\Support\ActivityLog::suppressed() so --dry-run and re-runs
  don't storm the activity log.

forms:verify-data-integrity {--strict}
  Nine coherence checks: schemas/fields/submissions/values/user_profiles
  structure, data migration counts (skipped when legacy tables absent),
  orphans, section/schema relation consistency, and strict reachability
  (opt-in). Runs all checks to completion; exit 1 on any failure.
  Validates binding JSON against config/form_binding.php registry and
  field_type against FormFieldType::values() ∪ custom_field_types config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:18:42 +02:00
85815ccb16 feat(forms): add Eloquent models, observer, events, activity-log helpers
Phase 4 of S1.

Models (app/Models/FormBuilder/): FormSchema, FormSchemaSection, FormField,
FormSubmission, FormValue, FormValueOption, FormTemplate, FormFieldLibrary,
FormSchemaWebhook, FormWebhookDelivery, FormSubmissionSectionStatus,
FormSubmissionDelegation. Plus UserProfile at app/Models/ (user-universal).

OrganisationScope applied on: FormSchema, FormTemplate, FormFieldLibrary.
FormSchemaWebhook documents inherited-scope discipline (OrganisationScope's
strategies — organisation_id/event_id/festival_section_id — don't cover
form_schema_id; direct queries would leak across orgs, so must go via
$schema->webhooks()).

User::profile()/getOrCreateProfile(), Event::formSchemas() (morphMany),
Person::formSubmissions() (morphMany).

Morph map enforced in AppServiceProvider with 28 keys covering every model
that appears as activitylog subject/causer. Also updated
OrganisationDashboardService (and its test) to query activitylog via
getMorphClass() instead of FQCN.

Activity log strategy: nuanced explicit calls (logSchemaChange on FormSchema,
logFieldChange on FormField) — no LogsActivity trait. Suppression for bulk
fixtures via App\Support\ActivityLog::suppressed(fn() => ...) which flips
config('activitylog.enabled') around a callback. Both our explicit calls
and spatie's trait on Organisation respect the flag via ActivityLogger::log().

FormValueObserver (app/Observers/FormBuilder/) populates value_indexed/
value_number/value_date/value_bool on save per field.value_storage_hint,
rebuilds form_value_options pivot on multi-value filterable fields, cleans
up on delete. Memoised field cache avoids N+1. Registered in AppServiceProvider.

9 lightweight event classes (app/Events/FormBuilder/) as SerializesModels
containers — submission lifecycle signatures lock in for S2 services, no
listeners yet.

Factories for all models with Dutch fake data (fake('nl_NL')). FormSchema
factory uses defaultSubmissionMode(); FormField factory uses
recommendedValueStorageHint().

Tests: 9 new observer tests (all pass); full suite 910/910 (up from 901).

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