Commit Graph

312 Commits

Author SHA1 Message Date
079d10975b refactor(form-builder): strict validator + drop form_fields.conditional_logic JSON column
WS-5c commit 3 of 4. FormRequests (Store/Update) now reject bad
conditional_logic trees at the HTTP boundary — the `after()` hook
unwraps the `show_when` envelope, normalises legacy `{all|any: [...]}`
group shape to the service's internal form, and delegates to
`FormFieldConditionalLogicService::assertSpecsValid()`. Unknown
operators, root conditions, empty groups, and unknown field_slug
references produce a 422 with a readable error before any write.

`form_fields.conditional_logic` JSON column dropped. FormField model
`$fillable` and `$casts` no longer mention the column; factory default
no longer writes `null` to it. Snapshot fixtures in the dev seeder and
the legacy-forms migration command keep `conditional_logic` in their
snapshot JSON shape — that's the schema_snapshot contract, not the DB
column.

FormFieldController now maps InvalidConditionalLogicSpecException to
422 alongside FrozenSchemaException / CyclicDependencyException.

Rollback path: roll back WS-5c commits 1–3 together. Partial rollback
(drop-column reversed but backfill still applied) is not a supported
state — matching the WS-5a/b precedent on the family's full-rollback
contract.

Tests: 6 new (strict FormRequest rejection cases + JSON-column drop
assertion). Rollback step counts in WS-5a/b migration tests bumped +1
for the drop_conditional_logic_json_column migration. Baseline
1142 → 1148 green (3085 → 3099 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:03:21 +02:00
d06ea01b09 feat(form-builder): FormFieldConditionalLogicService + cycle detection + legacy backfill + snapshot
WS-5c commit 2 of 4 — the service layer, backfill migration, and
read-path switch. Per addendum Q3, conditional_logic applies to
FormField only — no library mirror and no copyLogic on
FormFieldService::insertFromLibrary.

FormFieldConditionalLogicService owns every write:
  - logicFor(field): depth-limited eager-load of the tree
  - replaceLogic(field, tree): transactional structure + operator +
    field_slug validation + cycle check + activity-log emit
    (field.conditional_logic_replaced)
  - toJsonShape(root): reconstructs the canonical ARCH §8
    `{show_when: {...}}` shape — single source of truth for the
    snapshot writer + API resources
  - assertSpecsValid(tree): public boundary guard for the FormRequest
    strict validator (WS-5c commit 3 wires this up)
  - assertNoCycles(field, tree): contract preserved from
    FormFieldService::assertNoConditionalCycle, implementation now
    reads the relational adjacency.

Backfill migration translates pre-WS-5c conditional_logic JSON to
rows. Strict dispatch: unknown operators / unknown top-level keys /
malformed groups FAIL the migration — Phase A seed-scan confirmed
the catalogue parity, so any drift is a data bug to fix at source,
not silently absorb. Rollback rebuilds canonical JSON and clears
the relational tree.

FormFieldService.create/update route `conditional_logic` through
the new service (matching the extract-and-delegate pattern from
WS-5a bindings and WS-5b validation rules). Snapshot writer + both
resources (FormFieldResource, PublicFormSchemaResource) read via
`toJsonShape(rootConditionalLogicGroup())` — byte-for-byte parity
with the pre-WS-5c JSON contract.

InvalidConditionalLogicSpecException handled in FormFieldController
as 422, same as FrozenSchemaException / CyclicDependencyException.

Tests: 20 new under tests/Feature/FormBuilder/ConditionalLogic/
(service, cycle detection, backfill forward+rollback+failure cases,
snapshot + resource parity). FormFieldApiTest cyclic rejection test
rewritten to use the new factory state. Rollback step counts in
WS-5a/b migration tests bumped +1 for the new backfill migration.
Baseline 1122 → 1142 green (3032 → 3085 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:56:39 +02:00
2064b9901e feat(form-builder): form_field_conditional_logic_{groups,conditions} tables + OrganisationScope cap raise to 5
WS-5c commit 1 of 4 — relational infrastructure for the conditional-
logic tree that replaces form_fields.conditional_logic JSON (ARCH-
FORM-BUILDER §8; addendum Q3 WS-5c).

Tables: groups (nesting via parent_group_id) + conditions (leaves,
value JSON nullable for empty/not_empty). Simple FK to form_fields —
addendum Q3 explicitly excludes form_field_library from conditional_
logic scope, so no polymorphic morph here.

OrganisationScope cap raised 3 → 5 hops. The conditions chain is
4 hops (condition → group → field → schema → organisation_id column)
and the new cap gives headroom for future deeper trees without
denormalising form_field_id onto conditions.

Cascade observer (FormFieldChildTablesCascadeObserver) extended to
physically delete the new groups table on FormField delete (hard or
soft). Conditions cascade automatically via the group_id FK on the
groups table.

Factories: FormFieldConditionalLogicGroupFactory, FormFieldConditional
LogicConditionFactory, and FormFieldFactory::withConditionalLogic($tree)
for concise test fixtures.

Tests: 16 new under tests/Feature/FormBuilder/ConditionalLogic/
(relation, scope, cascade, enum catalogue). 3 new scope-cap tests in
ScopeLeakageTest verify 4/5-hop chains pass and 6-hop throws. Hardcoded
rollback step counts in WS-5a/b migration tests bumped for the 2 new
WS-5c migrations. Baseline 1104 → 1122 green (2988 → 3032 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:43:34 +02:00
500e5704e2 chore(sync): detect merge commits explicitly in post-commit hook 2026-04-24 23:00:49 +02:00
4fcff2367a docs(backlog): open FORM-BUILDER-LIBRARY-AUDIT-LOG — library-level activity-log gap surfaced during WS-5b review 2026-04-24 22:51:09 +02:00
4d207a5ff4 merge: WS-5b — form_field_validation_rules + form_field_configs, strict enum validator, drop validation_rules JSON, SCHEMA v2.4 + ARCH v1.6 2026-04-24 22:49:45 +02:00
d494478c08 feat(form-builder): form_field_configs relational table + non-validation key split + drop validation_rules JSON columns 2026-04-24 22:42:35 +02:00
9d2758a42c docs(form-builder): WS-5b partial sign-off — SCHEMA v2.3 + ARCH v1.5 §17.4 + addendum Q3 2026-04-24 22:30:17 +02:00
64ec4bcc5c refactor(form-builder): strict validator on save; strip rules.unique fallback 2026-04-24 22:26:44 +02:00
800b1b6c01 feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy 2026-04-24 22:12:08 +02:00
fedaed1b32 feat(form-builder): form_field_validation_rules table + polymorphic owner + scope + cascade 2026-04-24 22:01:36 +02:00
87fc964ead chore(sync): also trigger hook on .githooks/ changes 2026-04-24 21:08:35 +02:00
406b003e48 chore(sync): extend post-commit hook trigger to .claude-sync.conf and sync script 2026-04-24 21:04:13 +02:00
ff9fe0d749 chore(sync): track ARCH-CONSOLIDATION-ADDENDUM in claude-sync manifest 2026-04-24 20:57:45 +02:00
67bede2e49 docs(form-builder): WS-5a follow-up — §6.2 registry, §6.7 dual activity-log note, Q3 commit-3 caveat 2026-04-24 20:51:28 +02:00
8d3c1ec6b7 merge: WS-5a — form_field_bindings relational table + library mirror, pre-publish switched, SCHEMA v2.2 + ARCH v1.4 2026-04-24 20:50:10 +02:00
60c3abbe26 docs(form-builder): WS-5a — SCHEMA v2.2 §3.5.12, ARCH v1.4 §6.7, addendum Q3 sign-off 2026-04-24 20:13:51 +02:00
61719bf8bf refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns 2026-04-24 20:09:27 +02:00
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>
2026-04-24 18:48:47 +02:00
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>
2026-04-24 18:43:11 +02:00
76090b934e merge: WS-4 — ULID consistency, FK-chain scope, 14-model scope registration, D-05 verify 2026-04-24 17:31:56 +02:00
fe2202d835 test(persons): verify SoftDeletes behaviour per WS-1 finding D-05
Pre-flight audit verified the implementation was already in place,
so this commit is test-only (as the task prompt allowed):
- api/database/migrations/2026_04_08_220000_create_persons_table.php:27
  already carries $table->softDeletes().
- api/app/Models/Person.php lines 18 + 24 already use the SoftDeletes trait.

No production code change required.

tests/Feature/Person/PersonSoftDeleteTest.php pins the documented
behaviour so WS-6 (FormBindingApplicator + ARTIST_ADVANCE /
EVENT_REGISTRATION workflows) can rely on it without a second
verification pass:
- delete() sets deleted_at and excludes the row from default queries
- Person::withTrashed()->find($id) returns the soft-deleted row
- Person::onlyTrashed()->count() reports accurately
- restore() clears deleted_at
- forceDelete() removes the row permanently

PersonPolicy was spot-checked alongside the audit; existing view/update/
delete methods do not invoke withTrashed() on controller resolution.
That's acceptable today — no caller relies on editing a trashed Person —
so AUTH_ARCHITECTURE.md is left unchanged. If a restore-workflow lands
in a future sprint, the controller binding will need to switch to
->withTrashed() and the behaviour deserves a dedicated doc paragraph
then.

Minor path note: the task prompt specified
tests/Feature/Persons/PersonSoftDeleteTest.php (plural), but the
existing convention in this repo is tests/Feature/Person/ (singular).
Matched the existing directory rather than introducing a singular/plural
split.

Also carries the BACKLOG DOC-04 entry Bert asked for during WS-4 review:
scripts/install-claude-sync-hooks.sh belongs in SETUP.md / onboarding
so new clones don't miss the post-commit sync hook.

5 tests / 10 new assertions. Full suite: 1005 passed (2716 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:10:37 +02:00
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>
2026-04-24 17:08:33 +02:00
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>
2026-04-24 16:56:53 +02:00
a92ddc48ec refactor(schema): migrate eleven pivot/EAV tables to ULID per addendum Q1
Retires the "integer AI PK for join performance" exception documented
in earlier migrations and SCHEMA.md §3.5.11 Rule 1. Every business and
pivot table now uses ULID primary keys, per
/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1.

Tables migrated (WS-1 A-01 through A-11):
- Pure pivots: organisation_user, event_user_roles, crowd_list_persons,
  event_person_activations
- Model-backed: user_organisation_tags, person_section_preferences,
  mfa_backup_codes, mfa_email_codes, form_submission_section_statuses,
  form_values, form_value_options

Migration pattern: one new migration per table (plus one combined for
the form_values / form_value_options FK pair), timestamped today,
dropping + recreating with the new ULID PK. Pre-launch — no backfill
required. Original migrations remain in place; the new migrations
apply in timestamp order for a clean schema history.

Pivot model correction (addendum drift):
The addendum's "no model required for pure pivots" reading did not
account for Laravel's BelongsToMany::attach() — it cannot auto-generate
a pivot ULID without a Pivot subclass. Minimal Pivot classes under
app/Models/Pivots/ (OrganisationUser, EventUserRole, CrowdListPerson,
EventPersonActivation) carry HasUlids so attach() works. The six
belongsToMany relations (User.organisations / .events, Organisation.users,
Event.users, CrowdList.persons, Person.crowdLists) now ->using(...) the
appropriate Pivot class. DB::table()->insert() on event_person_activations
in DevSeeder populates the ULID inline via Str::ulid(). FormValueObserver
uses bulk FormValueOption::insert() which bypasses model events — ULIDs
are now generated inline there too.

Docs:
- SCHEMA.md §3.5.11 Rule 1 rewritten to mandate ULID on pivots too, with
  legacy note citing the addendum.
- All eleven table entries updated from "int AI PK" to "ULID PK" with
  addendum Q1 references.
- form_values and form_submission_section_statuses prose blocks updated
  to drop the retired ARCH §4.4 / "high-volume pivot" rationale.
- form_value_options.form_value_id column type corrected from
  "int FK" to "ULID FK".

Tests: tests/Feature/Schema/UlidPrimaryKeyTest.php covers HasUlids trait
presence, ULID shape + 26-char Crockford pattern, Route::bind resolution,
distinct + sortable pivot ULIDs, attach() auto-generation on pure pivots,
and the A-10/A-11 FK chain. 10 tests / 28 new assertions. Full suite:
977 passed (2662 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:38:08 +02:00
0041b3defa Add information in the documentation about Artist Advancing and Artist Handling 2026-04-24 14:53:27 +02:00
5a82497da4 docs(backlog): track Artist model prerequisite for artist_advance purpose
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:46:20 +02:00
598593b0db docs(schema,arch-form-builder): reflect purpose registry v1.0
- SCHEMA.md §3.5.12 header rewritten for the 7-purpose vocabulary and
  `PurposeRegistry`. The `custom_purpose_slug` column is dropped from
  the `form_schemas` table and removed from the index list. The
  `form_submissions.subject_type` note cites
  `PurposeRegistry::allSubjectTypes()` instead of the deleted
  `config/form_subjects.php`.
- ARCH-FORM-BUILDER.md TL;DR updated: goal bullet cites 7 purposes
  (v1.0); §3.2 bullet notes the legacy 22-variant vocabulary is
  retired. §17.3 replaced: the "Custom purposes per organisation"
  section is gone; the new "Purpose registry" section documents the
  seven-slug table, PurposeDefinition shape, PurposeRegistry API,
  MorphMapAlignmentTest guard, the pre-publish binding check, and a
  step-by-step "adding a new purpose" checklist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:36:18 +02:00
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>
2026-04-24 14:36:09 +02:00
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
b9343f6eec refactor(form-builder): drop custom purpose escape from schemas
Reduces the FormPurpose vocabulary from 22 variants + a `custom` escape
to the seven v1.0 purposes registered in the new PurposeRegistry.

- Purge migration deletes any form_schemas row whose `purpose` is not
  in the v1.0 set (cascades through form_fields, form_submissions,
  form_values, form_value_options, form_schema_sections,
  form_submission_section_statuses, form_submission_delegations,
  form_schema_webhooks, form_webhook_deliveries via existing FK).
- Drop migration removes the `custom_purpose_slug` column + its index.
- Both migrations declare their `down()` as a hard failure — we do not
  support reversing a purge (pre-launch, no production data).
- `FormPurpose` enum slims to the seven cases; the legacy helpers
  (defaultSubmissionMode / defaultSubjectType / allowsPublicAccess)
  now delegate to PurposeRegistry so callers keep working.
- FormSchema fillable / FormSchemaResource / StoreFormSchemaRequest /
  UpdateFormSchemaRequest / FormSchemaFactory drop every reference to
  `custom_purpose_slug` and the `custom` purpose.
- VerifyFormsDataIntegrity drops the custom-slug mismatch check and
  sources the subject-type allow-list from PurposeRegistry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:35:37 +02:00
e93207765b feat(form-builder): introduce purpose registry and definition value object
Lands the v1.0 purpose registry (WS-2 of the consolidation sprint) as a
first-class concept: a `PurposeDefinition` value object, a
`PurposeRegistry` service keyed by slug, and a declarative
`config/form_builder/purposes.php` registry with exactly the seven
purposes from ARCH-CONSOLIDATION §6.4.

Also rebuilds the morph-map in `AppServiceProvider::boot` into three
labelled blocks: (1) domain subject types derived from
`PurposeRegistry::allSubjectTypes()`, (2) non-purpose domain types
hardcoded with comments (form_schemas owner_types, activity-log
subjects), (3) framework types (spatie/activitylog; Sanctum stays
absent per addendum Q4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:35:18 +02:00
ad941cc944 docs: add architect decisions addendum after WS-1 2026-04-24 13:49:05 +02:00
4f47e054b9 docs: add WS-1 architecture discovery report 2026-04-24 13:08:00 +02:00
f9cc8c0a1b chore: add consolidation charter to claude-sync pipeline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:47:59 +02:00
79189a64e5 chore: add Claude Project Knowledge sync tooling
- scripts/sync-claude-docs.sh with sync/check/list/help subcommands
- scripts/install-claude-sync-hooks.sh for one-time hook setup
- .githooks/post-commit auto-syncs on dev-doc changes
- .githooks/pre-push warns (non-blocking) on stale sync
- .claude-sync.conf lists 11 synced documents
- SYNC_MANIFEST.md provides drift-detection anchor for Claude Chat
- package.json: npm run sync:docs | sync:check
- .gitignore excludes .claude-sync/ output directory

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:38:26 +02:00
83821b1bd5 docs(architecture): land consolidation sprint briefing document
Adds ARCH-CONSOLIDATION-2026-04.md as the authoritative reference for the
upcoming 8-workstream architecture consolidation sprint: purpose registry
cleanup, ULID consistency, JSON column split, binding infrastructure,
FormBindingApplicator, single-SPA consolidation to crewli.app, observability
foundation, docs consolidation.

Sprint scope, leading principles, workstream ordering, and chat-transition
protocol are captured in the document. Follow-up chats will start from this
document as primary context.

Also updates BACKLOG.md with an active-sprint marker pointing to the briefing.
2026-04-24 11:51:28 +02:00
a777c8335f docs(backlog): log TECH-08 for paginated response meta in organizer composables
Documents the technical debt introduced when the organizer API composables
(useSections, useFormSchemas) adopted a minimal PaginatedResponse shape
that discards Laravel's links and meta blocks. PR-b2 will surface the
first UI consumer that needs pagination controls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:32:40 +02:00
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