diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index 5f3e1f85..f319e2b0 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -24,8 +24,12 @@ Ten bullets every Claude Code session reads before starting: 1. **Goal:** replace the event-scoped `registration_form_fields` with a - polymorphic, universal form builder serving 22 distinct purposes from - event registration to incident reports to contract signatures. + polymorphic, universal form builder serving 7 distinct purposes + (v1.0) from event registration to incident reports to contract + signatures. Purposes are registered in + `config/form_builder/purposes.php` via `PurposeRegistry`; adding a + new purpose requires a code change (config + typically a listener) + — per ARCH-CONSOLIDATION §3 besluit 4. 2. **Four patterns:** entity-bound, submission-bound, event-registration, public. Every schema matches one. 3. **Three tables are core:** `form_schemas` (definitions), `form_fields` @@ -64,9 +68,12 @@ Ten bullets every Claude Code session reads before starting: 12. **Integration contracts are explicit.** The form builder's interaction with identity matching, crowd lists, shifts, and email notifications is contract-defined (§31). No ad-hoc cross-module coupling. -13. **Per-purpose lifecycles are documented.** Each of the 22 FormPurpose - values has a concrete lifecycle paragraph (§3.2) covering subject - handling, submission flow, integrations, and sample fields. +13. **Per-purpose lifecycles are documented.** Each of the 7 FormPurpose + values (v1.0) has a concrete lifecycle paragraph (§3.2) covering + subject handling, submission flow, integrations, and sample fields. + The wider vocabulary that once counted 22 variants is intentionally + retired in v1.0 — purposes outside the registered seven are not + part of the physical schema nor the behaviour spec. --- @@ -1976,19 +1983,72 @@ FormSchemaService uses the registry when validating field_type on create/ update. Built-in types are registered by default. Custom types extend the list without code changes to core. -### 17.3 Custom purposes per organisation +### 17.3 Purpose registry -`FormPurpose::CUSTOM` + `form_schemas.custom_purpose_slug` string. -Organisations define their own purposes (e.g., -`brandweertraining_certificering`). +The set of purposes served by the form builder is a closed, code-defined +vocabulary. There is no "custom purpose" escape. Organisations cannot +invent purposes at runtime. This is deliberate — purpose drives subject +handling, submission mode, public-access rules, pre-publish required +bindings, and downstream listeners; each of those needs explicit code +support. -Rules: -- `custom_purpose_slug` required when purpose=custom -- Unique(organisation_id, custom_purpose_slug) when purpose=custom -- Subject type and submission_mode must be explicitly set (no purpose- - derived defaults) -- Public token not allowed for custom purposes in v1 (future: per-slug - allowlist) +**v1.0 vocabulary** (seven purposes, defined in +`config/form_builder/purposes.php`): + +| Slug | Label (NL) | Subject type | Default submission mode | Public access | Required bindings | +|------|-----------|--------------|-------------------------|---------------|-------------------| +| `event_registration` | Aanmelding vrijwilligers/crew | person | single | yes | `person.email`, `person.first_name`, `person.last_name` | +| `artist_advance` | Artiest advance | artist | draft_single | no | — | +| `supplier_intake` | Leverancier intake | company | single | no | `company.name` | +| `post_event_evaluation` | Evaluatie na afloop | person | single | no | — | +| `incident_report` | Incident-melding | person | multiple | no | — | +| `signature_contract` | Contract-ondertekening | user | single | no | — | +| `user_profile` | Profiel-update | user | single | no | — | + +`allows_public_access` is the schema-level public-submission flag. +Portal-token-based flows (artists, suppliers, press) are a different +mechanism and do not consume this flag. + +**PurposeDefinition value object** (`app/FormBuilder/Purposes/ +PurposeDefinition.php`) holds the five properties above plus the slug. +It is immutable (`final readonly`) and `subjectType` uses the morph +alias, not the FQCN. + +**PurposeRegistry service** (`app/FormBuilder/Purposes/ +PurposeRegistry.php`) reads the config file, memoises the parsed +definitions per instance, and exposes: + +- `all()` — `array` +- `get(string $slug)` — throws `PurposeNotFoundException` on miss +- `has(string $slug)` — bool +- `allSubjectTypes()` — sorted, unique list of subject-type aliases; + consumed by `AppServiceProvider::registerMorphMap()` (domain-subject + block) and by `StoreFormSubmissionRequest` validation +- `publicAccessibleSlugs()` — slugs whose schemas permit public + submission + +`MorphMapAlignmentTest` guards the invariant that every subject_type +returned by `PurposeRegistry::allSubjectTypes()` is registered as a key +in `Relation::morphMap()`. + +**Required-bindings pre-publish check.** +`FormSchemaService::publish()` fails with +`PurposeRequirementsNotMetException` (structured; `purposeSlug` + +`missingBindings[]`) if any binding path in the schema's +`PurposeDefinition::requiredBindings` is not present on at least one +`form_fields.binding` JSON of the schema. In WS-5a this check switches +to the relational `form_field_bindings` table. + +**Adding a new purpose.** In scope only via an architect-level decision: + +1. Add a migration if existing schemas carry the new slug via data. +2. Add the new entry to `config/form_builder/purposes.php`. +3. If new subject type: register the FQCN in + `AppServiceProvider::PURPOSE_SUBJECT_FQCN`; `MorphMapAlignmentTest` + enforces this step. +4. Add listeners wired to `FormSubmissionSubmitted` as needed + (identity-match, tag sync, entity creation, etc.). +5. Add a lifecycle paragraph under §3.2 and a row to the table above. ### 17.4 Custom validation callbacks diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 309ffe07..5f66a51d 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1790,11 +1790,14 @@ Immutable audit record of every email sent. No soft deletes. ## 3.5.12 Form Builder > Universal form builder — one schema/field/submission/value stack serving -> 22 `FormPurpose` variants from `event_registration` to `incident_report` -> to `signature_contract`. See `/dev-docs/ARCH-FORM-BUILDER.md` v1.2 for -> the authoritative behaviour spec; this section documents the physical -> tables as landed through S1 + S2a + S2b. Where ARCH §4 and the -> migrations disagree, the migrations win (document code-as-built). +> the seven v1.0 `FormPurpose` variants (`event_registration`, +> `artist_advance`, `supplier_intake`, `post_event_evaluation`, +> `incident_report`, `signature_contract`, `user_profile`) registered in +> `config/form_builder/purposes.php` via `PurposeRegistry`. See +> `/dev-docs/ARCH-FORM-BUILDER.md` v1.3 for the authoritative behaviour +> spec; this section documents the physical tables as landed through +> S1 + S2a + S2b + WS-2. Where ARCH §4 and the migrations disagree, the +> migrations win (document code-as-built). > > **Legacy tables dropped (S2a):** `registration_form_fields`, > `person_field_values`, `registration_field_templates` removed by @@ -1870,8 +1873,7 @@ that aggregates the user's submitted, non-test `form_submissions`. | `owner_id` | ULID nullable | polymorph target | | `name` | string | | | `slug` | string | canonical within organisation | -| `purpose` | string(50) | `FormPurpose` enum value | -| `custom_purpose_slug` | string nullable | required when `purpose = custom` | +| `purpose` | string(50) | Slug matching a key in `PurposeRegistry::all()`; `FormPurpose` enum mirrors the seven v1.0 values | | `description` | text nullable | | | `is_published` | bool | default: false | | `submission_mode` | string(20) | `FormSubmissionMode` enum value | @@ -1897,7 +1899,7 @@ that aggregates the user's submitted, non-test `form_submissions`. | `deleted_at` | timestamp nullable | Soft delete | **Relations:** `belongsTo` organisation, createdBy/lastUpdatedBy/editLockUser (User); `morphsTo` owner; `hasMany` fields, sections, submissions, webhooks -**Indexes:** `(organisation_id, purpose)`, `(owner_type, owner_id)`, `(public_token)`, `(public_token_previous)`, `(custom_purpose_slug)` +**Indexes:** `(organisation_id, purpose)`, `(owner_type, owner_id)`, `(public_token)`, `(public_token_previous)` **Unique constraint:** `UNIQUE(organisation_id, slug)` **Global scope:** `OrganisationScope` **Soft delete:** yes @@ -2019,7 +2021,8 @@ that aggregates the user's submitted, non-test `form_submissions`. > One submission per `(schema, subject)` in `single` / `draft_single` > modes; unbounded in `multiple` mode. Polymorphic `subject_type` / -> `subject_id` (allowed types per `config/form_subjects.php`). +> `subject_id` (allowed types derived from +> `PurposeRegistry::allSubjectTypes()`; WS-2 Q6 consolidation). > Carries the lifecycle timestamps, review status, optional schema > snapshot (when `form_schemas.snapshot_mode != 'never'`), locale used, > idempotency key, anonymisation marker, and a `search_index` text