From 032ad9d9538cc9bde900c30722331c01cd638271 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 10:39:36 +0200 Subject: [PATCH] docs(architecture): upgrade form builder architecture to v1.2 --- dev-docs/ARCH-FORM-BUILDER.md | 2975 +++++++++++++++++++++++++++++++++ 1 file changed, 2975 insertions(+) create mode 100644 dev-docs/ARCH-FORM-BUILDER.md diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md new file mode 100644 index 00000000..da3c39ce --- /dev/null +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -0,0 +1,2975 @@ +# ARCH — Universal Form Builder (v1.2) + +> **Source of truth** for Crewli's universal Form Builder architecture. +> Any discrepancy with SCHEMA.md is resolved in favour of this document +> during the refactor. SCHEMA.md is updated at the end of the refactor. +> +> **Status:** Approved — about to enter S1 implementation +> **Version:** 1.2 (expanded with per-purpose lifecycles, integration +> contracts, user guidance principles, documentation coverage requirements, +> in-app copy catalogue, and concrete gap fills from v1.1 review) +> **Previous version:** 1.1 committed April 2026 +> **Created:** April 2026 +> **Owner:** Architecture doc; every session reads this before starting +> +> **Breaking change acceptance:** Crewli currently runs only as a development +> environment. Full refactor with breaking changes is acceptable and preferred +> over incremental non-breaking changes that would leave architectural debt. + +--- + +## 0. TL;DR — What every session must know + +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. +2. **Four patterns:** entity-bound, submission-bound, event-registration, + public. Every schema matches one. +3. **Three tables are core:** `form_schemas` (definitions), `form_fields` + (within schemas), `form_submissions` + `form_values` (results). + Auxiliary: `form_value_options`, `form_templates`, `form_field_library`, + `form_schema_webhooks`, `form_webhook_deliveries`, + `form_schema_sections`, `form_submission_section_statuses`, + `form_submission_delegations`, `user_profiles` (renamed, slimmed). +4. **Three binding patterns per field:** entity-owned (A), form-owned (B), + mirrored (C). Controlled via `form_fields.binding` JSON against a + server-side entity column registry. +5. **System-internal lookups bypass the form layer.** Reading + `user_profiles.bio` from a service happens directly on the table, never + through form_fields. Form layer is for UI rendering and editing only. +6. **Filtering is first-class.** `form_fields.is_filterable` + indexed + storage (`value_indexed`, `form_value_options` pivot) + filter-registry + endpoint. Tag-based filters stay on existing `user_organisation_tags`. +7. **Governance is built-in.** Retention policies, PII-flagging, consent + versioning, right-to-be-forgotten workflow with anonymisation. +8. **Schema lifecycle has three mechanisms:** `schema_version` (always + tracks edits), `schema_snapshot` per submission (for audit trails), + `freeze_on_submit` (prevents edits after first submission). See §18 + for interaction rules. +9. **Events are the backbone.** Every submission state change fires a + Laravel event. Webhooks, activity log, analytics, and integrations are + all listeners on these events — never ad-hoc in services. +10. **Pre-flight audit is mandatory.** Every session starts with the audit + block (§21) that verifies no accidental user_profile / volunteer_profile + changes slipped into the codebase before work begins. + +### [v1.2 addition] Three more principles: + +11. **User guidance is a first-class concern.** Every decision-impacting UI + control has contextual help (§28). Every destructive action has a + preview. No feature ships without in-app guidance + VitePress docs. +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. + +--- + +## Table of contents + +1. Rationale +2. Four form patterns +3. FormPurpose catalogue + - 3.1 Enum values summary + - 3.2 [v1.2] Per-purpose lifecycles +4. Core tables +5. FormFieldType catalogue +6. Field binding +7. Filter architecture +8. Conditional logic +9. Signature field details +10. Public token flow +11. Migration plan +12. Seeding strategy +13. Governance & compliance +14. Schema lifecycle & versioning +15. Workflows & submission reviews +16. Internationalisation +17. Extensibility & integrations (webhooks, custom field types, custom purposes) +18. Consistency & interaction rules +19. Self-hosting principle ("eat your own dog food") +20. Trade-offs & cost awareness +21. Pre-flight audit gate +22. Failure-mode resilience +23. Observability & analytics hooks +24. Access control per field +25. Self-hosting reservations (FormPurpose slots) +26. Open questions / deferred decisions +27. Out of scope +28. [v1.2] User guidance principles +29. [v1.2] Documentation coverage requirements per session +30. [v1.2] In-app copy catalogue (living seed) +31. [v1.2] Integration contracts + +--- + +## 1. Rationale + +Crewli has a proven registration form builder with EAV storage, 11 +system-seeded templates, and strong adoption. It handles event +registration well but is hard-coded to `event_id`. The same mechanism is +desired for: + +- Artist advancing intake (rider, contacts, production) +- Supplier intake (materials, power, transport) +- Persistent user/artist/company profile fields +- Post-event volunteer evaluations +- Signatures for contracts, code-of-conduct, accreditation receipt +- Incident reports during events +- Absence reports, check-out inventory +- Public complaints, press access requests, VIP RSVPs +- Onboarding and setup wizards +- Custom organisation-specific forms + +The legacy `volunteer_profiles` table (documented in SCHEMA v1.3 but +never implemented) mixed truly user-universal data (bio, photo) with +event-variable data (tshirt_size, allergies) and skill-like claims +(first_aid, driving_licence). Those are redistributed: + +- `tshirt_size`, `allergies`, `access_requirements` → `form_fields` per event +- `first_aid`, `driving_licence` → `person_tags` (system-seeded) +- `bio`, `photo_url`, `emergency_contact_*`, `reliability_score`, `is_ambassador` → stay on a renamed, slimmed `user_profiles` table + +The result: `user_profiles` becomes genuinely user-universal, the form +builder handles entity-specific and event-specific variation, and the +tag system handles skills and certificates. + +--- + +## 2. Four form patterns + +Every form in Crewli fits one of four patterns: + +| Pattern | Description | Subject | Examples | +|---|---|---|---| +| **Entity-bound** | Values persist on the subject. One submission per subject; updates overwrite. | user, artist, company, organisation | user_profile, artist_profile, company_profile | +| **Submission-bound** | Each submission is a standalone document with its own lifecycle. Multiple allowed per subject. | any, incl. null | incident_report, feedback, post_event_evaluation | +| **Event-registration** | Hybrid: one submission per person per event. Treated as entity-bound within the event's person context. | person | event_registration | +| **Public** | No authenticated subject. Accessed via public token. | null | public_complaint, public_rsvp | + +--- + +## 3. FormPurpose catalogue + +### 3.1 Enum values summary + +Every `form_schemas.purpose` is one of these values. Purpose determines +allowed `subject_type`, default `submission_mode`, and whether public +access is allowed. + +| Purpose | Subject type | Mode | Public? | Pattern | +|---|---|---|---|---| +| `event_registration` | person | draft_single | No | Event-registration | +| `user_profile` | user | single | No | Entity-bound | +| `artist_profile` | artist | single | No | Entity-bound | +| `company_profile` | company | single | No | Entity-bound | +| `artist_advance` | artist | draft_single | Via artist.portal_token | Entity-bound + sections | +| `supplier_intake` | company | draft_single | Via production_request.token | Entity-bound + sections | +| `incident_report` | user / null | multiple | No | Submission-bound | +| `feedback` | user / null | multiple | No | Submission-bound | +| `post_event_evaluation` | person | single | No | Submission-bound | +| `signature_contract` | user / artist | single | No | Submission-bound (frozen) | +| `signature_code_of_conduct` | user | single | No | Submission-bound (frozen) | +| `signature_receipt` | person | multiple | No | Submission-bound (frozen) | +| `absence_report` | person | multiple | No | Submission-bound | +| `check_out_inventory` | person | multiple | No | Submission-bound | +| `public_complaint` | null | multiple | Yes + captcha | Public | +| `public_press_request` | null | multiple | Yes + captcha | Public | +| `public_rsvp` | person / null | single | Yes via token | Public | +| `onboarding_wizard` | organisation | single | No | Entity-bound + sections | +| `event_setup_wizard` | event | single | No | Entity-bound + sections | +| `company_custom` | company | single | No | Entity-bound | +| `artist_custom` | artist | single | No | Entity-bound | +| `custom` | organisation-defined | organisation-defined | organisation-defined | Per §17.3 | + +### 3.2 [v1.2] Per-purpose lifecycles + +This section defines the concrete lifecycle of each FormPurpose: who +creates the schema, who submits, what integrations fire, and what the +canonical system-seeded template contains. Intended as binding spec for +S3 (seeding) and S5 (frontend rendering). + +#### 3.2.1 `event_registration` + +**Who creates the schema:** organiser (org_admin or event_manager) per event, +via form-builder UI. Seeded from `EventRegistrationDefault` template on +first event_registration form opened. + +**Who submits:** person (via portal, either as authenticated user or as +external person during public registration). + +**Submission flow:** +1. Person opens the registration form (authenticated or via public URL) +2. `FormSubmission.opened_at` = now; status = draft +3. Fields rendered per conditional_logic + role_restrictions +4. On submit: FormSubmissionSubmitted event fires +5. Integration: PersonIdentityService invoked (see §31.1) to detect match +6. Integration: if form contains AVAILABILITY_PICKER values, shift_assignments + are provisionally created at status=claim_pending (see §31.3) +7. Integration: if form contains SECTION_PRIORITY values, preferences are + stored in `person_section_preferences` (existing table) +8. Integration: confirmation email sent via CrewliMailable (see §31.4) + +**Canonical system template fields:** +- Naam (Pattern C → persons.first_name + last_name) +- E-mail (Pattern C → persons.email), required +- Telefoon (Pattern C → persons.phone) +- Geboortedatum (Pattern C → persons.date_of_birth), PII=true +- Shirtmaat (SELECT XS/S/M/L/XL/XXL, filterable=true) +- Dieetwensen (CHECKBOX_LIST, filterable=true) +- Allergieën (TEXTAREA, PII=true) +- Toegangsbehoeften (TEXTAREA, PII=true) +- Noodcontact naam (Pattern C → user_profiles.emergency_contact_name), PII=true +- Noodcontact telefoon (Pattern C → user_profiles.emergency_contact_phone), PII=true +- Certificaten & vaardigheden (TAG_PICKER) +- Motivatie (TEXTAREA, optional) +- Sectie-voorkeuren (SECTION_PRIORITY) +- Beschikbaarheid (AVAILABILITY_PICKER) +- Toestemming gegevensverwerking (BOOLEAN, required, consent-tracked) + +**Mode rationale:** draft_single — persons may fill across multiple sessions, +but only one registration per person per event is meaningful. + +**freeze_on_submit:** false. Organiser may want to add fields mid-campaign. + +**retention_days default:** 1095 (3 years, matches typical volunteer data retention). + +#### 3.2.2 `user_profile` + +**Who creates:** organiser per organisation (exactly one schema per +organisation, seeded automatically via `UserProfileDefault`). + +**Who submits:** authenticated user via portal (`/profile` page). + +**Submission flow:** +1. User opens profile; submission created on-demand if not exists +2. All fields are Pattern A (entity-owned → user_profiles columns) +3. User edits; on save, `updateOrCreate` on user_profiles +4. FormSubmissionSubmitted fires on any update +5. No confirmation email (user-initiated, no notification needed) + +**Canonical fields:** +- Bio (Pattern A → user_profiles.bio) +- Profielfoto (Pattern A → user_profiles.photo_url, IMAGE_UPLOAD) +- Noodcontact naam (Pattern A) +- Noodcontact telefoon (Pattern A) + +**Mode:** single (exactly one submission per user, updates overwrite). + +**freeze_on_submit:** false. + +**retention_days:** null (never anonymised — user profile persists while +account exists; deletion happens via user-delete flow, see §13.4). + +#### 3.2.3 `artist_profile` + +**Who creates:** org-level, one schema per organisation. + +**Who submits:** organiser on behalf of artist (via artist management UI), +or artist themselves via artist portal token. + +**Submission flow:** +1. Submission created when artist is created in system +2. Organisers fill/update artist profile fields +3. Public flow: artist clicks portal_token link, sees profile form +4. On save, entity updates (Pattern A for most fields) +5. FormSubmissionSubmitted fires + +**Canonical fields (initial set, extended when artist module lands):** +- Band/artist naam (Pattern A → artists.name) +- Contact naam (Pattern A → artists.contact_name) +- Contact e-mail (Pattern A → artists.contact_email) +- Genre (SELECT, filterable=true) +- Rider-notities (TEXTAREA) +- Technische rider (FILE_UPLOAD, PDF) + +**Mode:** single. + +**freeze_on_submit:** false. + +**retention_days:** null (artist relationships persist across events). + +#### 3.2.4 `company_profile` + +Mirror of `artist_profile` but for company entities. One schema per org, +companies get a row on creation. Pattern A/C mix for contact fields. + +**Canonical fields:** contact_name, contact_email, contact_phone, website, +company-type (supplier/partner/etc.), KvK-nummer, BTW-nummer. + +#### 3.2.5 `artist_advance` + +**Who creates:** org-level schema (one per org), seeded from +`ArtistAdvanceDefault`. Schema has `section_level_submit=true`. + +**Who submits:** artist via portal_token (no authentication required). + +**Submission flow:** +1. Artist receives email with portal link (triggered by organiser) +2. Opens link → token resolved, submission fetched or created +3. Form shown with sections: General Info, Contacts, Production (power, + catering, transport), Technical Rider, Hospitality +4. Artist fills sections independently; each section submit separately +5. On section submit: FormSubmissionSectionSubmitted event fires +6. Organiser reviews per section → section status approved/rejected/ + changes_requested +7. When all sections approved: FormSubmissionSubmitted fires at submission level +8. Integration: triggers accreditation generation per ARCH-07 (future) + +**Mode:** draft_single. + +**freeze_on_submit:** false during draft; true after all sections approved. + +**retention_days:** 2555 (7 years, contractual retention). + +#### 3.2.6 `supplier_intake` + +**Who creates:** org-level schema seeded from `SupplierIntakeDefault`. Uses +`section_level_submit=true`. + +**Who submits:** supplier via production_request.token. + +**Submission flow:** analogous to artist_advance. Sections: Company info, +Personnel list (TABLE_ROWS), Materials (TABLE_ROWS), Power requirements, +Transport + logistics, Accreditation requests. + +Integration with existing `production_requests` infrastructure (see §31.6). + +#### 3.2.7 `incident_report` + +**Who creates:** org-level schema from `IncidentReportDefault`. + +**Who submits:** any authenticated user (volunteer, crew, organiser) during +an event. Also available via public form if organiser enables public token +(rare — typically internal only). + +**Submission flow:** +1. User accesses via "Rapporteer incident" link in portal (event-scoped) +2. Submission has subject_type=user (submitter), event context in metadata +3. `freeze_on_submit=true` — once submitted, cannot be edited (integrity) +4. Organiser reviews via incident dashboard; review_status workflow applies +5. Integration: critical incidents notify org_admins via CrewliMailable +6. `schema_snapshot` stored for legal retention + +**Canonical fields:** +- Tijdstip incident (DATETIME, required, defaults to now) +- Locatie (TEXT, required) +- Type incident (SELECT: medisch/veiligheid/schade/conflict/anders, filterable) +- Ernst (SELECT: laag/middel/hoog/kritiek, filterable) +- Betrokkenen (TEXTAREA) +- Beschrijving (TEXTAREA, required) +- Ondernomen actie (TEXTAREA, required) +- Foto's (IMAGE_UPLOAD, multiple, max 5) +- Politie/ambulance gebeld? (BOOLEAN) + +**Mode:** multiple (same user can report multiple incidents). + +**freeze_on_submit:** true. + +**retention_days:** 3650 (10 years, legal minimum). + +**snapshot_mode:** on_submit. + +#### 3.2.8 `feedback` + +**Who creates:** per-event or per-org schema. + +**Who submits:** volunteer / crew / public visitor (public optional). + +**Submission flow:** simple form-submit, fires FormSubmissionSubmitted, +organiser can browse responses. + +**Canonical fields:** waardering (NUMBER 1-5), wat ging goed (TEXTAREA), +wat kan beter (TEXTAREA), zou je terugkomen (BOOLEAN). + +**Mode:** multiple. + +**retention_days:** 365. + +#### 3.2.9 `post_event_evaluation` + +**Who creates:** per-event schema, triggered by organiser after event closes. + +**Who submits:** person who participated (subject = person). + +**Submission flow:** +1. Organiser clicks "Verstuur evaluatie" in event dashboard +2. Emails sent to all approved persons (see §31.4) +3. Person clicks link, fills form +4. On submit: FormSubmissionSubmitted fires +5. Aggregation dashboard populates from submissions + +**Canonical fields:** +- Algemene waardering (NUMBER 1-5) +- Shift-waardering (NUMBER 1-5) +- Was de briefing duidelijk? (NUMBER 1-5) +- Wil je terugkomen? (BOOLEAN) +- Opmerkingen (TEXTAREA) +- Verbeterpunten (TEXTAREA) +- Anoniem? (BOOLEAN) — if true, submitted_by_user_id cleared on submit + +**Mode:** single (one evaluation per person per event). + +**snapshot_mode:** on_submit (responses frozen post-event). + +**retention_days:** 365. + +#### 3.2.10 `signature_contract` + +**Who creates:** per-org, optionally per artist/event. + +**Who submits:** user or artist via authenticated / token flow. + +**Submission flow:** +1. Schema contains PARAGRAPH with contract text + SIGNATURE field +2. On submit: signature hashed (§9), stored +3. submission locked (`freeze_on_submit=true` enforced) +4. PDF can be generated later (out of scope for v1) + +**Mode:** single. + +**retention_days:** 2555 (7 years fiscal). + +**snapshot_mode:** on_submit. + +#### 3.2.11 `signature_code_of_conduct` + +Per-event schema. User must sign code-of-conduct before participating. +Linked from portal "Gedragscode tekenen" action. Integration: person cannot +claim shifts until code-of-conduct submission exists with status=submitted +(see §31.5). + +**Fields:** PARAGRAPH (code text from org config) + BOOLEAN (Ik ga akkoord) ++ SIGNATURE. + +**Mode:** single per person per event. + +#### 3.2.12 `signature_receipt` + +Per-event, per-person-per-accreditation. Used at accreditation desk: +volunteer signs for received wristband / t-shirt / meal vouchers. + +**Fields:** TABLE_ROWS (items received with quantities) + SIGNATURE. + +**Mode:** multiple (different receipts at different moments). + +**freeze_on_submit:** true. + +#### 3.2.13 `absence_report` + +**Who creates:** per-event optional schema. If not present, absence is +logged via simple status change on shift_assignment. + +**Who submits:** person via portal when they realise they can't show up. + +**Submission flow:** +1. Person clicks "Ik kan toch niet komen" on shift detail +2. Form opens, subject = person +3. Fields: reason (SELECT), notes (TEXTAREA), affected_shifts (AVAILABILITY_PICKER + showing their assigned shifts) +4. On submit: for each affected shift, ShiftAssignment.status → cancelled with + cancellation_source="volunteer_absence" +5. FormSubmissionSubmitted fires; organiser notification sent + +**Mode:** multiple. + +**Integration:** see §31.3 (shifts) and §31.4 (email). + +#### 3.2.14 `check_out_inventory` + +Accreditation-desk form used at end of event when volunteer returns gear. + +**Fields:** TABLE_ROWS (items returned, condition per item), SIGNATURE of +accreditation officer + volunteer. + +**Integration:** links to accreditation module (ARCH-07, future) — at that +point the pre-checkin items list is auto-populated. + +#### 3.2.15 `public_complaint` + +Public form accessible via crewli.app/f/{token}. Captcha required. + +**Fields:** name (optional), email (required), subject (TEXT), message +(TEXTAREA), event (SELECT from org's public events). + +**Rate limit:** 3 per IP per hour. + +**Integration:** triggers email to configured complaints-mailbox (org-level +config). + +#### 3.2.16 `public_press_request` + +Similar to public_complaint but for press accreditation requests. Fields: +outlet name, contact details, press card upload, event(s) requested. +Captcha required. + +#### 3.2.17 `public_rsvp` + +VIP RSVP form. Public URL with token. subject_type may be null (truly +public) or person (specific VIP invited). Fields: name, email, attending +(BOOLEAN), plus_ones (NUMBER), dietary_preference. + +#### 3.2.18 `onboarding_wizard` + +Self-hosted meta-form. Used when a new organisation is created — the +organisation-admin fills org details, brand, contact person, etc. + +**Uses:** `section_level_submit=true`. Sections: Organisation basics, Brand +& communication, Invite members, Create first event. + +**Pattern A** for all fields — writes directly to organisations table +(organisation is subject). + +**Once complete:** schema is archived per organisation; never submitted again. + +#### 3.2.19 `event_setup_wizard` + +Similar to onboarding_wizard but per-event. Sections: Basic details, Crowd +types, Registration fields, Communication. Replaces the currently multi- +page event setup with a guided form. + +#### 3.2.20 `company_custom` + +Organisation-specific company fields. E.g., org tracks "preferred bank +account" or "fire safety certified?" on companies. One schema per org, +fields added by org_admin. + +Pattern B for all custom fields (form-owned). Shows up in company detail +as a tab. + +#### 3.2.21 `artist_custom` + +Analogous to company_custom but for artists. + +#### 3.2.22 `custom` + +See §17.3 — organisations define their own purposes via +`custom_purpose_slug`. Subject type and mode must be explicitly set. + +--- +## 4. Core tables + +### 4.1 `form_schemas` + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations | +| `owner_type` | string nullable | polymorph: `event` / `user_profile` / `artist` / `company` / null | +| `owner_id` | ULID nullable | polymorph target | +| `name` | string | | +| `slug` | string | Canonical within org | +| `purpose` | enum | FormPurpose | +| `custom_purpose_slug` | string nullable | Required when purpose=custom (see §17.3) | +| `description` | text nullable | | +| `is_published` | bool, default false | | +| `submission_mode` | enum | FormSubmissionMode | +| `public_token` | ULID nullable, unique | For public schemas | +| `public_token_previous` | ULID nullable | For graceful rotation | +| `public_token_rotated_at` | datetime nullable | | +| `submission_deadline` | datetime nullable | | +| `locale` | string, default 'nl' | Primary locale; translations in form_fields.translations | +| `settings` | JSON nullable | Opaque UI config | +| `version` | int, default 1 | Bumped on any schema-affecting edit | +| `snapshot_mode` | enum, default 'never' | never / on_submit / always — see §14 | +| `freeze_on_submit` | bool, default false | After first submitted submission, no more edits | +| `retention_days` | int nullable | After submitted_at + retention_days: anonymise | +| `consent_version` | string nullable | e.g. "privacy-v2" | +| `section_level_submit` | bool, default false | Enables sections-with-own-submit | +| `auto_save_enabled` | bool, default false | Client does periodic silent saves | +| `max_submissions` | int nullable | Optional cap for public schemas | +| `created_by_user_id` | ULID FK nullable | Audit: who created this schema | +| `last_updated_by_user_id` | ULID FK nullable | Audit: who last edited | +| `edit_lock_user_id` | ULID FK nullable | Pessimistic lock holder for collaborative editing | +| `edit_lock_expires_at` | datetime nullable | Auto-release after this time | +| `created_at`, `updated_at`, `deleted_at` | | Soft delete | + +**Relations:** hasMany form_fields, hasMany form_submissions, hasMany form_schema_webhooks, hasMany form_schema_sections, belongsTo organisation, morphsTo owner, belongsTo createdBy/lastUpdatedBy/editLockUser (User) + +**Indexes:** `(organisation_id, purpose)`, `(owner_type, owner_id)`, `(public_token)` partial where not null, `(public_token_previous)` partial, `(custom_purpose_slug)` partial + +**Unique:** `(organisation_id, slug)` + +**Activity log:** yes — via spatie/laravel-activitylog on create/update/delete + +### 4.2 `form_fields` + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas, cascadeOnDelete | +| `form_schema_section_id` | ULID FK nullable | → form_schema_sections (when section_level_submit) | +| `field_type` | string | One of FormFieldType or registered custom type (see §17.2) | +| `slug` | string | Unique within schema | +| `label` | string | Default-locale label | +| `help_text` | text nullable | | +| `section` | string nullable | Section header name (visual grouping, different from section_level_submit) | +| `options` | JSON nullable | Choice options | +| `validation_rules` | JSON nullable | min/max/regex/allowed_mime_types/etc. | +| `is_required` | bool, default false | | +| `is_filterable` | bool, default false | See §7 | +| `is_portal_visible` | bool, default true | | +| `is_admin_only` | bool, default false | Convenience for common role restriction | +| `is_unique` | bool, default false | Value must be unique across submissions (DB-enforced via partial index; see §4.2.1) | +| `is_pii` | bool, default false | Marks field as personal data (governance, §13) | +| `display_width` | enum: half/full | | +| `binding` | JSON nullable | See §6 | +| `conditional_logic` | JSON nullable | See §8 | +| `role_restrictions` | JSON nullable | See §24 | +| `translations` | JSON nullable | `{ "en": { "label": "...", "help_text": "...", "options": [...] } }` | +| `value_storage_hint` | enum, default 'json' | json / string / number / date / bool — enables typed columns in form_values | +| `review_required` | bool, default false | Individual fields needing review (§15) | +| `sort_order` | int | | +| `library_field_id` | ULID FK nullable | → form_field_library (linked reusable definition, §17.4) | +| `created_at`, `updated_at`, `deleted_at` | | Soft delete preserves submission history | + +**Relations:** belongsTo form_schema, belongsTo form_schema_section (nullable), belongsTo libraryField (form_field_library), hasMany form_values + +**Indexes:** `(form_schema_id, sort_order)`, `(form_schema_id, is_filterable)`, `(library_field_id)` partial + +**Unique:** `(form_schema_id, slug)` on non-deleted + +**Activity log:** yes — via spatie/laravel-activitylog + +**Soft delete:** yes — deleting a field must not break historical submissions + +#### 4.2.1 [v1.2] `is_unique` enforcement + +When `is_unique=true`, values for this field must be unique across all +submissions of the same schema. Enforcement: + +- **Application-level:** FormValueService validates on write, rejects with + 422 if duplicate exists among submitted submissions of the same schema +- **Database-level:** partial UNIQUE index + `UNIQUE(form_field_id, value_indexed) WHERE form_field.is_unique = true + AND form_submission.status = 'submitted'` + — This is implemented via a database-level trigger or application-enforced + check because partial indexes with cross-table predicates are engine- + specific. Preferred: application-enforced for portability, with a + periodic integrity check job that flags violations for admin review. + +### 4.3 `form_submissions` + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas | +| `subject_type` | string nullable | polymorph | +| `subject_id` | ULID nullable | polymorph target | +| `submitted_by_user_id` | ULID FK nullable | Authenticated submitter | +| `public_submitter_name` | string nullable | Public submitters | +| `public_submitter_email` | string nullable | Public submitters | +| `public_submitter_ip` | string nullable | For audit/security | +| `public_submitter_ip_anonymised_at` | datetime nullable | [v1.2] Set when IP is anonymised (default 30 days post-submit unless investigation flag) | +| `status` | enum | FormSubmissionStatus: draft / submitted / archived | +| `review_status` | enum nullable | null / pending_review / approved / rejected / changes_requested | +| `reviewed_by_user_id` | ULID FK nullable | | +| `reviewed_at` | datetime nullable | | +| `review_notes` | text nullable | | +| `submitted_at` | datetime nullable | | +| `schema_version_at_submit` | int nullable | The schema.version at submit time | +| `schema_snapshot` | JSON nullable | Full snapshot when schema.snapshot_mode dictates (§14) | +| `is_test` | bool, default false | Test/preview submissions excluded from reporting | +| `submitted_in_locale` | string nullable | Locale submitter used while filling in | +| `opened_at` | datetime nullable | First time the form was rendered | +| `first_interacted_at` | datetime nullable | First field focus/input | +| `submission_duration_seconds` | int nullable | opened_at → submitted_at | +| `auto_save_count` | int, default 0 | Debug/analytics counter | +| `idempotency_key` | ULID nullable | Client-provided key preventing duplicate submits | +| `anonymised_at` | datetime nullable | Set by anonymisation service (§13) | +| `search_index` | text nullable | Concatenated text values for full-text search (§23) | +| `created_at`, `updated_at`, `deleted_at` | | Soft delete | + +**Relations:** belongsTo form_schema, hasMany form_values, hasMany form_submission_section_statuses, hasMany form_submission_delegations, belongsTo submittedBy / reviewedBy (User), morphsTo subject + +**Indexes:** `(form_schema_id, status)`, `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)` partial where not null, `FULLTEXT(search_index)` (MySQL) + +**Unique:** `(form_schema_id, idempotency_key)` partial where not null + +**Soft delete:** yes + +**Events fired:** See §17.1 — FormSubmissionCreated, FormSubmissionDraftUpdated, FormSubmissionSubmitted, FormSubmissionReviewed, FormSubmissionAnonymised, FormSubmissionArchived, FormSubmissionDeleted + +### 4.4 `form_values` + +| Column | Type | Notes | +|---|---|---| +| `id` | int AI | PK — integer for join performance | +| `form_submission_id` | ULID FK | → form_submissions, cascadeOnDelete | +| `form_field_id` | ULID FK | → form_fields | +| `value` | JSON | Universal storage; interpretation via field_type | +| `value_indexed` | string(255) nullable | For single-value filtering (§7) | +| `value_number` | decimal(15,4) nullable | Populated when value_storage_hint=number | +| `value_date` | date nullable | Populated when value_storage_hint=date | +| `value_bool` | bool nullable | Populated when value_storage_hint=bool | +| `value_anonymised` | bool, default false | Set by anonymisation | + +**Indexes:** `(form_submission_id, form_field_id)`, `(form_field_id, value_indexed)` partial where value_indexed not null, `(form_field_id, value_number)` partial where value_number not null, `(form_field_id, value_date)` partial where value_date not null + +**Unique:** `(form_submission_id, form_field_id)` + +**Observer:** On upsert, populates value_indexed / value_number / value_date / value_bool / form_value_options based on field.value_storage_hint and field.is_filterable + +### 4.5 `form_value_options` (filter pivot for multi-value fields) + +| Column | Type | Notes | +|---|---|---| +| `id` | int AI | PK | +| `form_value_id` | int FK | → form_values, cascadeOnDelete | +| `form_field_id` | ULID FK | Denormalised for fast filtering | +| `form_submission_id` | ULID FK | Denormalised | +| `option_value` | string(255) | Single selected option | + +**Indexes:** `(form_field_id, option_value)`, `(form_submission_id)`, `(form_value_id)` + +### 4.6 `form_templates` + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations | +| `name` | string | | +| `slug` | string | | +| `purpose` | enum | FormPurpose — constrains target schemas | +| `description` | text nullable | | +| `schema_snapshot` | JSON | Full schema with fields, order, config | +| `is_system` | bool | true = shipped with Crewli | +| `is_active` | bool | Deactivate without deleting | +| `created_at`, `updated_at` | | | + +**Indexes:** `(organisation_id, purpose, is_active)` + +**Unique:** `(organisation_id, slug)` + +#### 4.6.1 [v1.2] `schema_snapshot` JSON structure + +The snapshot stored in `form_templates.schema_snapshot` AND in +`form_submissions.schema_snapshot` follows this canonical shape: + +```json +{ + "schema_version": 3, + "snapshot_created_at": "2026-04-17T14:00:00Z", + "schema": { + "name": "...", + "slug": "...", + "purpose": "event_registration", + "description": "...", + "locale": "nl", + "freeze_on_submit": false, + "section_level_submit": false, + "consent_version": "privacy-v2", + "settings": {} + }, + "sections": [ + { + "id": "01H...", + "slug": "general", + "name": "Algemeen", + "sort_order": 1, + "depends_on_section_slug": null, + "required_for_schema_submit": true + } + ], + "fields": [ + { + "id": "01H...", + "slug": "shirtmaat", + "field_type": "SELECT", + "label": "Shirtmaat", + "help_text": null, + "section_slug": null, + "options": ["XS","S","M","L","XL","XXL"], + "validation_rules": {"required": true}, + "is_required": true, + "is_filterable": true, + "is_pii": false, + "binding": null, + "conditional_logic": null, + "translations": {}, + "value_storage_hint": "json", + "sort_order": 5 + } + ] +} +``` + +Rationale: using slugs (not IDs) in cross-references so snapshots are +portable between orgs. library_field_id references are resolved to +inline definitions at snapshot time (see §4.7). + +### 4.7 `form_field_library` (cross-schema reusable definitions) + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations | +| `name` | string | "Shirtmaat (standaard)" | +| `slug` | string | | +| `field_type` | string | | +| `label` | string | Default label when library field is inserted | +| `help_text` | text nullable | | +| `options` | JSON nullable | | +| `validation_rules` | JSON nullable | | +| `default_is_required` | bool, default false | | +| `default_is_filterable` | bool, default false | | +| `default_binding` | JSON nullable | | +| `translations` | JSON nullable | | +| `description` | text nullable | Admin-only description of intended use | +| `usage_count` | int, default 0 | Cached count of form_fields.library_field_id = this.id | +| `is_system` | bool | true = shipped with Crewli | +| `is_active` | bool | | +| `created_at`, `updated_at` | | | + +**Relations:** hasMany form_fields (via library_field_id) + +**Indexes:** `(organisation_id, field_type)`, `(organisation_id, is_active)` + +**Unique:** `(organisation_id, slug)` + +When an organiser inserts a library field into a schema, a new `form_fields` +row is created with `library_field_id = library.id` and values copied from +the library definition. The inserted field is independent thereafter — +editing it does not affect the library, and vice versa. The `library_field_id` +reference exists for analytics ("used 34 times") and for "update all copies" +workflows later. + +### 4.8 `form_schema_sections` (for section-level submit) + +Only used when `form_schemas.section_level_submit = true`. + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas, cascadeOnDelete | +| `slug` | string | [v1.2] Added for snapshot portability | +| `name` | string | "Technical Rider" | +| `description` | text nullable | | +| `sort_order` | int | | +| `submit_independent` | bool, default true | Whether this section can be submitted independently | +| `depends_on_section_id` | ULID FK nullable | → self; section is locked until dependency is approved | +| `required_for_schema_submit` | bool, default true | If all required sections must be submitted+approved to consider schema submit complete | + +**Indexes:** `(form_schema_id, sort_order)` + +**Unique:** `(form_schema_id, slug)` + +#### 4.8.1 [v1.2] Cycle detection + +When saving a section with `depends_on_section_id`, FormSchemaService +validates no cycle is created (depth-first traversal of depends_on_section_id +chain). Rejected with 422 on cycle detection. + +### 4.9 `form_submission_section_statuses` + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_submission_id` | ULID FK | → form_submissions, cascadeOnDelete | +| `form_schema_section_id` | ULID FK | → form_schema_sections | +| `status` | enum | draft / submitted / approved / rejected / changes_requested | +| `submitted_at` | datetime nullable | | +| `reviewed_by_user_id` | ULID FK nullable | | +| `reviewed_at` | datetime nullable | | +| `review_notes` | text nullable | | + +**Unique:** `(form_submission_id, form_schema_section_id)` + +### 4.10 `form_submission_delegations` + +For scenarios like "Nina fills in my advance on my behalf". + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_submission_id` | ULID FK | → form_submissions, cascadeOnDelete | +| `delegated_to_user_id` | ULID FK | → users | +| `delegated_by_user_id` | ULID FK | → users (the subject who granted) | +| `granted_at` | datetime | | +| `revoked_at` | datetime nullable | | +| `message` | text nullable | Optional context from delegator to delegatee | + +**Indexes:** `(delegated_to_user_id, revoked_at)`, `(form_submission_id)` + +### 4.11 `form_schema_webhooks` + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas, cascadeOnDelete | +| `name` | string | "Zapier — New registration" | +| `trigger_event` | enum | submission_created / submission_submitted / submission_reviewed / section_submitted / section_approved / section_rejected | +| `url` | string (encrypted) | Webhook endpoint; validated against allowlist/blocklist | +| `secret` | string (encrypted) nullable | HMAC secret for payload signing | +| `is_active` | bool, default true | | +| `created_at`, `updated_at` | | | + +**Indexes:** `(form_schema_id, is_active)` + +### 4.12 `form_webhook_deliveries` + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_schema_webhook_id` | ULID FK | → form_schema_webhooks | +| `form_submission_id` | ULID FK | → form_submissions | +| `trigger_event` | enum | Same as webhook's trigger_event | +| `status` | enum | pending / delivered / failed / dead_letter | +| `attempts` | int, default 0 | | +| `last_attempt_at` | datetime nullable | | +| `response_status` | int nullable | HTTP status code | +| `response_body_excerpt` | text nullable | First 1000 chars of response | +| `next_retry_at` | datetime nullable | | +| `delivered_at` | datetime nullable | | +| `failed_permanently_at` | datetime nullable | | +| `payload_snapshot` | JSON | What was sent (for replay/audit) | + +**Indexes:** `(status, next_retry_at)`, `(form_schema_webhook_id, status)`, `(form_submission_id)` + +### 4.13 `user_profiles` (renamed from legacy volunteer_profiles, slimmed) + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `user_id` | ULID FK unique | → users, cascadeOnDelete | +| `bio` | text nullable | | +| `photo_url` | string nullable | | +| `emergency_contact_name` | string nullable | | +| `emergency_contact_phone` | string nullable | | +| `reliability_score` | decimal(3,2), default 0.00 | System-computed | +| `is_ambassador` | bool, default false | System-awarded | +| `settings` | JSON nullable | Strict: opaque UI/notification preferences only (see §4.13.1) | +| `created_at`, `updated_at` | | | + +**Computed attributes (Laravel accessors):** +- `last_submitted_at` — `max(form_submissions.submitted_at WHERE subject=this user AND status=submitted AND is_test=false)` + +**Relations:** belongsTo User + +**Indexes:** `(reliability_score)` + +**Unique:** `(user_id)` + +**No soft delete** — tightly coupled to user; cascades on user delete + +#### 4.13.1 [v1.2] `settings` scope enforcement + +`user_profiles.settings` is a strictly-scoped JSON column. Allowed +top-level keys are defined in `config/form_builder.php`: + +```php +'user_profile_settings_whitelist' => [ + 'ui.theme', + 'ui.sidebar_collapsed', + 'ui.time_format', + 'notifications.email_digest', + 'notifications.shift_reminders', + 'notifications.event_updates', +], +``` + +**Forbidden:** any queryable business attribute, org-specific preferences +(those belong in `organisation_user` pivot), anything identity-related. + +**Enforcement:** `UserProfileSettingsValidator` custom rule on the +update-request. Rejects keys not in whitelist with 422. + +**Locale** lives on `users.locale` (already exists). Not duplicated here. + +--- + +## 5. FormFieldType catalogue + +### 5.1 Built-in types + +| Type | Stored value | UI widget | Filterable? | +|---|---|---|---| +| `TEXT` | string | VTextField single-line | Yes | +| `TEXTAREA` | string | VTextarea | No | +| `EMAIL` | string | VTextField type=email | Yes | +| `PHONE` | string (E.164) | VTextField | Yes | +| `NUMBER` | number | VTextField type=number | Yes (via value_number) | +| `DATE` | ISO date | VDatePicker | Yes (via value_date) | +| `DATETIME` | ISO datetime | VDatePicker + time | Yes | +| `BOOLEAN` | bool | VSwitch | Yes (via value_bool) | +| `RADIO` | string (one option) | Radio group | Yes | +| `SELECT` | string | VSelect | Yes | +| `MULTISELECT` | string[] | VSelect multiple | Yes via form_value_options | +| `CHECKBOX_LIST` | string[] | Checkbox group | Yes via form_value_options | +| `FILE_UPLOAD` | string (path) | VFileInput | No | +| `IMAGE_UPLOAD` | string (path) | VFileInput images only | No | +| `SIGNATURE` | object (§9) | Signature pad | No | +| `TAG_PICKER` | string[] (tag IDs) | Tag autocomplete | Yes via user_organisation_tags | +| `HEADING` | no value | H3 | n/a | +| `PARAGRAPH` | no value | Prose block | n/a | +| `URL` | string | VTextField URL | Yes | +| `SECTION_PRIORITY` | `{ section_id, priority }[]` | Drag-to-prioritise | No | +| `AVAILABILITY_PICKER` | ULID[] (time_slot IDs) | Checkbox per slot | No | +| `TABLE_ROWS` | `{ [col_slug]: value }[]` | Dynamic rows editor | No | + +Custom field types registered via `CustomFieldTypeRegistry` (§17.2) extend +this list dynamically. The `field_type` column is stored as string, not +DB enum, to allow this. + +### 5.2 [v1.2] Mapping to value_storage_hint + +| Field type | Recommended value_storage_hint | +|---|---| +| TEXT, TEXTAREA, EMAIL, PHONE, URL | string | +| NUMBER | number | +| DATE, DATETIME | date | +| BOOLEAN | bool | +| RADIO, SELECT | string | +| Everything else | json (default) | + +The hint is suggestive, not enforced — organiser can override. Observer +populates typed columns accordingly. + +--- + +## 6. Field binding + +### 6.1 The three patterns + +**Pattern A — Entity-owned (`binding.mode = "entity_owned"`):** +Value lives in an entity column. Form field is a rendering surface. +No `form_values` row created. Reading: entity column. Writing: entity column. + +**Pattern B — Form-owned (default, `binding = null`):** +Value lives in `form_values`. No entity column involved. Pure dynamic EAV. + +**Pattern C — Mirrored (`binding.mode = "mirrored"`):** +Value written to entity column AND `form_values`. Entity is source of truth +going forward; form_values is historical audit. + +### 6.2 Entity column registry + +Server-side config file `config/form_binding.php`: + +```php +return [ + 'user_profile' => [ + 'bio' => ['type' => 'text', 'label' => 'Bio', 'writable' => true], + 'photo_url' => ['type' => 'image', 'label' => 'Profielfoto', 'writable' => true], + 'emergency_contact_name' => ['type' => 'string', 'label' => 'Noodcontact naam', 'writable' => true], + 'emergency_contact_phone' => ['type' => 'string', 'label' => 'Noodcontact telefoon', 'writable' => true], + ], + 'person' => [ + 'first_name' => ['type' => 'string', 'label' => 'Voornaam', 'writable' => true], + 'last_name' => ['type' => 'string', 'label' => 'Achternaam', 'writable' => true], + 'email' => ['type' => 'string', 'label' => 'E-mail', 'writable' => true], + 'phone' => ['type' => 'string', 'label' => 'Telefoon', 'writable' => true], + 'date_of_birth' => ['type' => 'date', 'label' => 'Geboortedatum', 'writable' => true], + 'admin_notes' => ['type' => 'text', 'label' => 'Notities', 'writable' => true, 'admin_only' => true], + ], + 'company' => [ + 'contact_first_name' => ['type' => 'string', 'label' => 'Contact voornaam', 'writable' => true], + 'contact_last_name' => ['type' => 'string', 'label' => 'Contact achternaam', 'writable' => true], + 'contact_email' => ['type' => 'string', 'label' => 'Contact e-mail', 'writable' => true], + 'contact_phone' => ['type' => 'string', 'label' => 'Contact telefoon', 'writable' => true], + ], + 'artist' => [ + // populated when artist module lands + ], + 'organisation' => [ + 'name' => ['type' => 'string', 'label' => 'Organisatienaam', 'writable' => true], + 'slug' => ['type' => 'string', 'label' => 'Slug', 'writable' => true], + 'contact_name' => ['type' => 'string', 'label' => 'Contactpersoon', 'writable' => true], + 'contact_email' => ['type' => 'string', 'label' => 'Contact-e-mail', 'writable' => true], + 'phone' => ['type' => 'string', 'label' => 'Telefoon', 'writable' => true], + 'website' => ['type' => 'string', 'label' => 'Website', 'writable' => true], + ], +]; +``` + +Only registered columns are valid binding targets. Form Request validates +at save time. + +### 6.3 Binding JSON specification + +```json +// Pattern B (default) +"binding": null + +// Pattern A +"binding": { + "mode": "entity_owned", + "entity": "user_profile", + "column": "bio" +} + +// Pattern C +"binding": { + "mode": "mirrored", + "entity": "user_profile", + "column": "emergency_contact_name", + "sync_direction": "write_on_submit" +} +``` + +### 6.4 Read/write semantics per pattern + +| Operation | Pattern A | Pattern B | Pattern C | +|---|---|---|---| +| **Initial render** | Read entity column | Read form_value (null if new) | Read entity column | +| **Save draft** | Update entity | Upsert form_value | Update entity + upsert form_value | +| **Final submit** | Update entity | Upsert form_value | Update entity + upsert form_value | +| **Historical view** | Not possible — always current | Show stored form_value | Show stored form_value | +| **form_values row?** | No | Yes | Yes | +| **Filter source** | Entity column | form_values.value_indexed / value_number / etc. | Entity column (preferred) | + +### 6.5 Binding-change safety + +Changing a field's binding is a DANGEROUS OPERATION. Rules: + +- If schema has zero submissions: free to change +- If schema has submissions but all are drafts: allowed with warning +- If schema has submitted submissions: REJECTED unless organiser provides + explicit `?force_binding_change=true` query param AND the change is + logged with elevated audit level +- Changing binding from B → A/C: orphans all historical form_values for + that field (they remain in DB but no longer the source of truth). + Documented in activity log. +- Changing binding from A/C → B: entity columns retain their values; new + submissions start storing in form_values again + +Always logged via activity log with old/new binding for audit. + +### 6.6 Cross-entity binding + +Pattern C fields on event_registration schemas (subject = person) CAN bind +to `user_profile` columns IF `person.user_id` is set. When user_id is null +(external person), the mirror write is **skipped gracefully** and logged. +form_values row is still written in both cases. + +--- + +## 7. Filter architecture + +A `form_fields.is_filterable = true` marks a field as queryable in list +views. + +### 7.1 Filter strategy per field type + +| Field type | Filter mechanism | Storage | +|---|---|---| +| TEXT, EMAIL, PHONE, URL | LIKE on value_indexed | form_values.value_indexed | +| NUMBER | Range on value_number | form_values.value_number | +| DATE, DATETIME | Range on value_date / value_indexed | form_values.value_date / value_indexed | +| BOOLEAN | Exact on value_bool | form_values.value_bool | +| RADIO, SELECT | Exact/IN on value_indexed | form_values.value_indexed | +| MULTISELECT, CHECKBOX_LIST | JOIN on form_value_options | form_value_options | +| TAG_PICKER | JOIN on user_organisation_tags | existing tag pivot | +| Pattern A/C entity-owned | WHERE on entity column | entity column | +| TEXTAREA, FILE_*, SIGNATURE, HEADING, PARAGRAPH, TABLE_ROWS, SECTION_PRIORITY, AVAILABILITY_PICKER | Not filterable | n/a — is_filterable forced to false | + +### 7.2 Observer mechanics + +When a form_value is upserted for a field with `is_filterable=true`: +- Single-value types: `value_indexed` = cast to string(255), truncated with + warning log if longer +- Number type: `value_number` = cast to decimal(15,4) +- Date type: `value_date` = cast to date +- Bool type: `value_bool` = cast to bool +- Multi-value types: rebuild form_value_options (delete all, insert current) + +When `is_filterable=false`: all indexed columns NULL, pivot rows removed. + +When toggling `is_filterable`: queued job backfills (or clears) existing +submissions. Job is observable (metric on queue depth). + +### 7.3 Filter registry endpoint + +Example: Personen-module + +``` +GET /api/v1/organisations/{org}/persons/filter-registry?event_id={ulid?} +``` + +Response: +```json +{ + "data": [ + { "source": "entity_column", "key": "crowd_type_id", "label": "Crowd Type", "field_type": "SELECT", "options": [...] }, + { "source": "entity_column", "key": "status", "label": "Status", "field_type": "SELECT", "options": [...] }, + { "source": "tags", "key": "tags", "label": "Vaardigheden", "field_type": "TAG_PICKER", "options": [...] }, + { + "source": "form_field", + "key": "form_field:01HZ...", + "form_field_id": "01HZ...", + "schema_slug": "...", + "label": "Shirtmaat", + "field_type": "SELECT", + "options": ["XS","S","M","L","XL","XXL"] + } + ] +} +``` + +Caching: response cached on `(organisation_id, purpose, event_id?)` with +invalidation via form_schema/form_field activity log events. + +### 7.4 [v1.2] Filter registry — entity column sources + +The `entity_column` source in the filter registry is **separate** from the +binding registry (§6.2). Filterable entity columns per context are defined +in `config/form_filter_registry.php`: + +```php +return [ + 'persons' => [ + 'crowd_type_id' => ['label' => 'Crowd Type', 'field_type' => 'SELECT', 'options_source' => 'crowd_types'], + 'status' => ['label' => 'Status', 'field_type' => 'SELECT', 'options_enum' => PersonStatus::class], + 'is_blacklisted' => ['label' => 'Uitgesloten', 'field_type' => 'BOOLEAN'], + ], + 'companies' => [...], + 'events' => [...], +]; +``` + +Rationale: not every bindable column is filterable (admin_notes shouldn't +be filtered), and not every filterable column is bindable (PersonStatus +is enum-driven, not user-editable via form). + +### 7.5 Applying filters in list endpoints + +A generic `FilterQueryBuilder` service: +- Takes URL params and the filter-registry definition +- Joins the appropriate tables based on source +- Applies WHERE/HAVING as appropriate +- Respects role-based access (a filter on admin_only field is rejected for + non-admin requester with 403) + +### 7.6 Organiser UX + +Form-builder UI shows "Gebruik als filter in overzichten" checkbox per +field. Disabled with tooltip for non-filterable types. Hint on enabling: +"Dit wordt extra geïndexeerd voor snelle filtering — alleen aanvinken voor +velden die je echt als filter gebruikt." + +--- + +## 8. Conditional logic + +`form_fields.conditional_logic` JSON: + +```json +{ + "show_when": { + "all": [ + { "field_slug": "has_allergies", "operator": "equals", "value": true } + ] + } +} +``` + +Operators: equals, not_equals, contains, not_contains, in, not_in, +greater_than, less_than, empty, not_empty. + +Groups: `all` (AND), `any` (OR), nestable. + +References: by `field_slug` within same schema. Cyclic dependencies +rejected at save time via depth-first traversal check. + +--- + +## 9. Signature field + +Stored `value` JSON structure: + +```json +{ + "file_path": "signatures/01HZ.../signature.png", + "disk": "s3", + "signed_at": "2026-04-17T14:23:11Z", + "signer_name": "Bert Hausmans", + "signer_ip": "10.0.0.1", + "hash": "sha256:..." +} +``` + +Hash = SHA-256 of `file_bytes + signed_at + signer_name + signer_ip`. +Computed server-side on submit. + +Once submitted, a signature form_value is immutable. The whole submission +transitions to an archived-locked state preventing further edits. + +### 9.1 [v1.2] Storage disk configuration + +File paths relative to disk specified in signature value. Disk comes from +`config('filesystems.default')` unless overridden per field: +```json +"validation_rules": { "storage_disk": "s3-private" } +``` + +### 9.2 [v1.2] Signature verification + +`SignatureVerificationService::verify(FormValue $value): bool`: +- Reads file bytes from stored disk + path +- Recomputes SHA-256 using stored signed_at/signer_name/signer_ip +- Compares with stored hash +- Returns true/false + +Used by: admin UI ("Verify integrity" button on submitted signatures), +legal-export endpoint (out of scope for v1, but service exists from day 1). + +Integrity-mismatch triggers activity log entry at warning level AND +notification to org_admin. + +--- + +## 10. Public token flow + +Schemas with public purposes get a `public_token` ULID. + +URL: `https://app.crewli.app/f/{public_token}` + +Rules: +- GET on token URL: returns schema + empty submission state +- POST on token URL: creates submission; subject_type = null (or derived + from token) +- No authentication required +- Rate-limited per IP per schema per hour (configurable, default 5) +- Captcha REQUIRED for truly public purposes: public_complaint, + public_press_request +- Captcha NOT required for entity-token flows (artist_advance via + artist.portal_token; supplier_intake via production_request.token) +- public_submitter_email: collected but never echoed in public responses + +Token rotation: setting a new `public_token` moves current to +`public_token_previous` and stamps `public_token_rotated_at`. Requests +using the previous token are accepted for a grace period (default 7 +days), allowing users to complete already-opened forms. After grace: old +token returns 410 Gone. + +### 10.1 [v1.2] Captcha provider + +Cloudflare Turnstile (free, privacy-first, EU-friendly). Configuration in +`config/form_builder.php`: + +```php +'captcha' => [ + 'provider' => 'turnstile', + 'site_key' => env('TURNSTILE_SITE_KEY'), + 'secret_key' => env('TURNSTILE_SECRET_KEY'), + 'required_for_purposes' => ['public_complaint', 'public_press_request'], +], +``` + +Frontend embeds Turnstile widget. Backend validates token via +`CaptchaVerificationService::verify(string $token, string $remoteIp): bool` +before allowing submission. + +Invalid captcha → 422 with clear error. + +### 10.2 [v1.2] Rate limit storage + +Uses Laravel's native `RateLimiter` facade. Backing store is +`config('cache.default')` — typically Redis in production, array-cache +in tests. + +Rate limit key format: `form-submit:{public_token}:{ip}` + +When limit hit: 429 Too Many Requests, with `Retry-After` header. + +### 10.3 [v1.2] public_submitter_ip retention + +`public_submitter_ip` is stored for audit/abuse-prevention. Automatically +anonymised after 30 days post-submission by scheduled job (configurable +via `config/form_builder.php.public_submitter_ip_retention_days`). + +When anonymised: IP replaced with `"[anonymised]"` and +`public_submitter_ip_anonymised_at` set. + +Exception: if submission is flagged for investigation (organiser marks it), +anonymisation is paused until flag is cleared. + +--- + +## 11. Migration plan + +Breaking change. No dual-support phase. Frontend updated in same PR. + +### 11.1 Migration order + +1. Create new tables: form_schemas, form_submissions, form_value_options, + form_field_library, form_schema_sections, form_submission_section_statuses, + form_submission_delegations, form_schema_webhooks, form_webhook_deliveries, + user_profiles +2. Rename: `registration_form_fields` → `form_fields`; add form_schema_id + (nullable), keep event_id temporarily +3. Rename: `person_field_values` → `form_values`; add form_submission_id + (nullable), keep person_id + registration_form_field_id temporarily +4. Rename: `registration_field_templates` → `form_templates` +5. Run data-migration script (§11.2) +6. Drop legacy columns: form_fields.event_id, form_values.person_id, + form_values.registration_form_field_id +7. Apply new indexes + +Each step is a separate migration file with reversible `down()`. + +### 11.2 Data migration script + +Runs per organisation inside a transaction. Fails fast on unexpected shapes. + +``` +For each distinct event_id in (legacy) form_fields: + 1. Create form_schemas row: + - organisation_id = event.organisation_id + - owner_type = 'event', owner_id = event.id + - purpose = 'event_registration' + - name = event.name + ' registratie' + - slug = event.slug + '-registratie' + - is_published = (event status allows it) + - submission_mode = 'draft_single' + - version = 1 + - snapshot_mode = 'never' + - created_by_user_id = first event_user_roles.user_id WHERE role=admin + 2. Update form_fields rows for that event: + - form_schema_id = newly created schema's id + - Preserve slug, field_type, config + - Set is_pii via heuristic (see §11.2.1) + 3. For each distinct person with form_values for this event: + - Create form_submissions: + - form_schema_id = schema.id + - subject_type = 'person', subject_id = person.id + - submitted_by_user_id = person.user_id (may be null) + - status = 'submitted' (if person.status >= applied) else 'draft' + - submitted_at = MIN(form_values.created_at for that person) + - schema_version_at_submit = 1 + - is_test = false + - Update form_values: + - form_submission_id = submission.id + - For value_storage_hint != 'json': backfill typed columns + - For is_filterable fields: backfill value_indexed / form_value_options + 4. For each registration_field_template: + - Compute schema_snapshot + - Create form_templates row with purpose='event_registration' + +After all organisations processed: + - Seed system templates per org (idempotent) + - Seed system person_tags per org (idempotent) + - Seed form_field_library system fields per org (idempotent) + +Verification queries: + - Count old registration_form_fields = count new form_fields + - Count old person_field_values = count new form_values + - Every form_field has non-null form_schema_id + - Every form_value has non-null form_submission_id + - Sample submission round-trip: render matches old view + - Orphan check: no form_values with null form_submission_id + - Orphan check: no form_values referencing non-existent form_field_id +``` + +#### 11.2.1 [v1.2] PII heuristic + +For legacy fields without is_pii flag: + +```php +$piiSlugPatterns = [ + 'email', 'phone', 'telefoon', 'adres', 'address', + 'emergency_contact', 'noodcontact', 'noodnummer', + 'geboort', 'birthdate', 'birth_date', 'dob', + 'allergie', 'allergy', 'medisch', 'medical', + 'dieet', 'diet', + 'toegangs', 'access', + 'bsn', 'social_security', + 'iban', 'bank', +]; + +$piiFieldTypes = ['EMAIL', 'PHONE']; + +$isPii = in_array($field->field_type, $piiFieldTypes) + || collect($piiSlugPatterns)->contains(fn($p) => str_contains(strtolower($field->slug), $p)); +``` + +Organisers can fine-tune after migration via form-builder UI. + +### 11.3 Rollback + +Each migration has `down()`. Rollback restores legacy columns (event_id, +person_id, registration_form_field_id) from the new structure before +renaming tables back. + +### 11.4 Migration rehearsal (mandatory) + +Before running on real data: +1. Take a full dump of dev DB +2. Run migration on a copy +3. Run comparison script: per organisation, compare pre/post counts and + sample 10 random submissions, assert identical rendering +4. If any mismatch: stop, investigate +5. Only after clean rehearsal run on main dev DB + +--- + +## 12. Seeding strategy + +### 12.1 System form templates (on org creation, idempotent) + +Every new organisation gets these with `is_system = true`. Detailed field +lists in §3.2 (per-purpose lifecycles). + +- **EventRegistrationDefault** (§3.2.1) +- **UserProfileDefault** (§3.2.2) +- **ArtistAdvanceDefault** (§3.2.5) +- **SupplierIntakeDefault** (§3.2.6) +- **IncidentReportDefault** (§3.2.7) +- **PostEventEvaluationDefault** (§3.2.9) +- **SignatureContractDefault** (§3.2.10) +- **SignatureCodeOfConductDefault** (§3.2.11) +- **SignatureReceiptDefault** (§3.2.12) +- **FeedbackDefault** (§3.2.8) + +System templates can be customised per org but not deleted; only +deactivated. + +### 12.2 System form_field_library entries (on org creation, idempotent) + +Common reusable fields seeded per organisation: + +- **Shirtmaat** (SELECT, options: XS/S/M/L/XL/XXL, is_filterable=true) +- **Dieetwensen** (CHECKBOX_LIST, options: vegetarisch/veganistisch/ + glutenvrij/lactosevrij/halal/kosher, is_filterable=true) +- **Noodcontact naam** (TEXT, is_pii=true) +- **Noodcontact telefoon** (PHONE, is_pii=true) +- **Geboortedatum** (DATE, is_pii=true) +- **Toestemming AVG** (BOOLEAN, required, consent field) +- **Opmerkingen** (TEXTAREA) + +Organiser can insert these into any schema with one click. + +### 12.3 System person_tags (on org creation, idempotent) + +**Certificates (category: Certificaat):** +EHBO, BHV, VCA-VOL, VCA-BASIS, SVH Sociale Hygiëne, Horeca-diploma, +Keuringsregister, Rijbewijs A, Rijbewijs B, Rijbewijs BE, Rijbewijs C, +Rijbewijs CE, Aanhangwagen-rijbewijs, Heftruckcertificaat, +Hoogwerker-certificaat, AED-training. + +**Languages (category: Taal):** +Engels, Duits, Frans, Spaans, Italiaans, Arabisch, Turks, Pools, +Portugees. + +**Skills (category: Vaardigheid):** +Tapper, Barista, Kassa-ervaring, Security-ervaring, Podium-ervaring, +Techniek-ervaring, Logistiek-ervaring, Cateringervaring, +Schoonmaak-ervaring, Verkeersregelaar, Parkeerplaats-ervaring. + +Seeded via `PersonTagSystemSeeder` using `updateOrCreate` keyed on +`(organisation_id, name)`. Organisations can deactivate seeded tags; +cannot delete (hidden flag `is_system_seed = true`). + +--- + +## 13. Governance & compliance + +### 13.1 PII-flagging + +`form_fields.is_pii = true` marks a field as personal data. Effects: +- Included in PII-audit endpoint: `GET /organisations/{org}/forms/pii-fields` +- Treated as sensitive in exports (red border, extra confirmation) +- Subject to retention_days when set +- Displayed with PII-indicator icon in organiser UI + +### 13.2 Retention policies + +`form_schemas.retention_days` (nullable int). When set: +- Scheduled job `FormSubmissionRetentionJob` runs daily at 03:00 +- Finds submissions where `submitted_at < NOW() - retention_days` AND + `is_test = false` AND `anonymised_at IS NULL` +- Calls FormSubmissionAnonymisationService (§13.3) +- Sends aggregate notification email to org_admin at end of run: + "{count} submissions anonymised today for organisation X" + +### 13.3 Right-to-be-forgotten (anonymisation) + +`FormSubmissionAnonymisationService::anonymise(FormSubmission)`: +- For each form_value of a submission: + - If field.is_pii: set value='[ANONYMISED]', value_indexed=null, + value_number/date/bool=null, value_anonymised=true + - If signature field: delete signature file, replace value with + `{anonymised: true}` + - If file_upload field: delete file, replace value with + `{anonymised: true, original_filename_redacted: true}` +- Set submission.anonymised_at = NOW() +- Clear submission.search_index +- Log activity log entries per field (one entry per anonymised field) +- Fire FormSubmissionAnonymised event + +Called by: +- Admin action via `POST /form-submissions/{id}/anonymise` (org_admin only) +- Automatic retention job based on retention_days +- GDPR user-initiated deletion request (see §13.4) + +### 13.4 Right-to-be-forgotten workflow (GDPR) + +When a user requests data deletion (§31.2 integration contract): + +1. All `form_submissions` where `subject_type = 'user'` AND + `subject_id = that_user` are anonymised +2. All `form_submissions` where `submitted_by_user_id = that_user`: + `submitted_by_user_id` → null; name/email copied to + public_submitter_* before clearing +3. `user_profiles` row deleted (cascade via FK) +4. `user_organisation_tags` for that user deleted +5. Activity log entries for that user anonymised (causer replaced with + "[deleted-user]" but action records preserved) + +**GDPR export endpoint (§31.2):** +``` +GET /api/v1/users/{user}/gdpr-export +``` +Returns JSON with all user-owned data. Organiser-initiated via admin action, +or user-initiated via portal profile page. Format: JSON (v1). PDF export +deferred. + +### 13.5 Consent tracking + +`form_schemas.consent_version` — when a submission is made, the schema's +current consent_version is snapshotted into the submission's +schema_snapshot (if snapshot_mode != 'never') OR logged in activity log +at minimum. Organiser can demonstrate which consent text the submitter +accepted. + +### 13.6 Test submissions + +`form_submissions.is_test = true`: +- Excluded from all list/aggregate endpoints by default +- Excluded from retention policies (never anonymised) +- Excluded from export endpoints +- Accessible only via explicit `include_test=true` query param +- Created via "Test submission" button in form preview +- Do not fire webhooks +- Do not count toward `max_submissions` cap + +Organiser can create test submissions without cluttering real data. +Test submissions are purged manually via a "Clear test data" action. + +--- +## 14. Schema lifecycle & versioning + +Three independent mechanisms; each with a distinct purpose. + +### 14.1 `form_schemas.version` + +Always active. Integer, starts at 1, bumped on any schema-structural edit +(fields added/removed/reordered, required/optional changes, options +changes). Bump triggered by FormSchemaService on save. Used for: +- Detecting schema drift between current and submission-time state +- Generating ETags for caching +- Cache invalidation + +Version bumps do NOT produce separate activity log entries when +`snapshot_mode=never` — the field-level activity log entries already cover +the actual change. When `snapshot_mode=on_submit` or `always`, the version +bump is logged for cross-reference with stored snapshots. + +### 14.2 `form_schemas.snapshot_mode` + `form_submissions.schema_snapshot` + +Controls whether and when full schema snapshots are stored per submission: +- `never` (default) — no snapshot; trust version number + audit log for history +- `on_submit` — snapshot stored when submission transitions to status=submitted +- `always` — snapshot stored on any save, including drafts + +Used for high-stakes submissions (contracts, signature_receipt, +post_event_evaluation for regulatory retention). Trade-off: more storage, +but bulletproof audit trail. + +Snapshot structure defined in §4.6.1. + +### 14.3 `form_schemas.freeze_on_submit` + +If true: once the schema has at least one submission with status=submitted, +the schema becomes read-only at the field-structure level. Labels/help_text +can still be edited (lightly); fields cannot be added, removed, or have +their type changed. Bypass requires elevated permission and creates a +loud audit log entry. + +Complementary to version + snapshot, not overlapping: freeze_on_submit +prevents edits; version tracks them; snapshot preserves history. + +### 14.4 Test/preview submissions + +`form_submissions.is_test = true` for: +- Submissions created from form preview mode +- Submissions created with an `X-Crewli-Test-Mode: true` header + +See §13.6 for test submission semantics. + +### 14.5 [v1.2] Edit-lock mechanism + +`form_schemas.edit_lock_user_id` + `edit_lock_expires_at` implement +pessimistic locking for collaborative editing: + +- When organiser A opens the form-builder in edit mode: acquire lock with + `edit_lock_user_id = A`, `edit_lock_expires_at = now() + 10 minutes` +- Heartbeat: every 2 minutes while edit is open, extend + `edit_lock_expires_at` by another 10 minutes +- When organiser B tries to open the same schema in edit mode: + - If current lock expired: auto-release, B acquires + - If current lock still valid: 409 Conflict with holder's name + expiration time + - B can force-steal via explicit confirmation (creates audit log entry) +- On save/close: release lock +- On logout/connection loss: heartbeat stops, lock auto-expires + +Frontend UX: lock banner shows "Schema wordt bewerkt door {naam} (nog {X} +minuten)" with "Vraag toegang" button that triggers force-steal flow. + +### 14.6 [v1.2] Form preview mode + +Preview endpoint: +``` +GET /api/v1/organisations/{org}/form-schemas/{schema}/preview +``` + +Returns the schema in render-ready format with sample field values that +drive conditional_logic. No submission created; values are mocked +per-field-type (TEXT = "Lorem ipsum", SELECT = first option, etc.). + +Frontend renders preview in a side-panel or separate route without the +normal submit flow (submit button disabled or replaced with "Dit is een +voorbeeld"). + +Organiser can toggle "Test mode" in preview — triggers actual submission +with `is_test=true` so they can verify the whole flow end-to-end. + +--- + +## 15. Workflows & submission reviews + +### 15.1 Simple review flow + +Every submission supports review regardless of schema: +- `review_status` on form_submissions: null / pending_review / approved / + rejected / changes_requested +- Set via `POST /form-submissions/{id}/review` with body + `{ status, review_notes }` +- Fires FormSubmissionReviewed event +- Activity log records reviewer, decision, notes + +### 15.2 Section-level submit (for advancing) + +When `form_schemas.section_level_submit = true`: +- form_schema_sections define independent submit units +- form_submission_section_statuses track per-section state +- Each section has its own submit/review flow +- Section dependencies (`depends_on_section_id`) gate later sections until + earlier ones are approved + +### 15.3 Delegation + +For "Nina fills in my advance on my behalf": +- Subject grants delegation via `POST /form-submissions/{id}/delegate` +- Delegated user can view and edit that submission +- Delegator retains full control and can revoke +- Delegation audited via activity log + +Note: delegation is per-submission, not per-schema. Organisers don't grant +delegations — subjects do. + +### 15.4 Multi-step gating + +Via `form_schema_sections.depends_on_section_id`. A section is locked +(read-only, clearly marked) until the dependency's section_status is +'approved'. Used for workflows like "finish general info first, then rider +details unlock". + +### 15.5 [v1.2] Review workflow notifications + +On status change: +- `pending_review` → notify org_admins (or event_managers if schema is + event-scoped) via CrewliMailable +- `approved` → notify submitter (if authenticated user) via CrewliMailable +- `rejected` / `changes_requested` → notify submitter with review_notes + in email + +Notification flow is asynchronous via FormSubmissionReviewed event +listener `SendReviewNotificationListener`. Recipients configurable per +schema via `form_schemas.settings.review_notification_recipients` +(array of user_ids or role names). + +--- + +## 16. Internationalisation + +### 16.1 Per-field translations + +`form_fields.translations` JSON: +```json +{ + "en": { + "label": "T-shirt size", + "help_text": "What size do you wear?", + "options": ["XS", "S", "M", "L", "XL", "XXL"] + }, + "de": { + "label": "T-Shirt Größe", + "help_text": "Welche Größe trägst du?", + "options": ["XS", "S", "M", "L", "XL", "XXL"] + } +} +``` + +If no translation exists for a locale, fallback applies (§16.2). + +### 16.2 Locale fallback chain + +`FormLocaleResolver` service determines the effective locale: +1. `submitter.locale` (if submitter is authenticated) +2. Explicit locale passed in request (via `Accept-Language` header) +3. `form_schemas.locale` (schema's primary locale) +4. App default locale (`config('app.locale')`) + +Each field label/help_text/options resolved per-locale, with graceful +fallback to default-locale values when translation is missing. + +### 16.3 Timezone handling + +DATETIME values are stored as UTC ISO-8601. Display-time conversion to +submitter's timezone: +1. `users.timezone` (if authenticated) +2. `organisation.timezone` +3. App default (`config('app.timezone')`) + +`submitted_in_locale` on form_submissions stores the locale used at +submit time for audit. + +### 16.4 Runtime resolution deferred + +For v1, translations column EXISTS but runtime locale resolution is +minimal (schema.locale + fallback only). Full per-field runtime swap is +deferred; v1 renders in the schema's primary locale with translations +column available for future activation. + +--- + +## 17. Extensibility & integrations (webhooks, custom field types, custom purposes) + +### 17.1 Laravel events + +Every submission state change fires a Laravel event. Registered in +`EventServiceProvider`. + +Submission lifecycle events: +- `FormSubmissionCreated(FormSubmission $submission)` — on insert +- `FormSubmissionDraftUpdated(FormSubmission $submission, array $changedFields)` — on draft save (rate-limited to once per 30s to prevent spam) +- `FormSubmissionSubmitted(FormSubmission $submission)` — status → submitted +- `FormSubmissionReviewed(FormSubmission $submission, string $outcome)` — review_status change +- `FormSubmissionAnonymised(FormSubmission $submission)` — on anonymisation +- `FormSubmissionArchived(FormSubmission $submission)` — status → archived +- `FormSubmissionDeleted(FormSubmission $submission)` — soft-deleted +- `FormSubmissionSectionSubmitted(FormSubmission, FormSchemaSection)` — section submit +- `FormSubmissionSectionReviewed(FormSubmission, FormSchemaSection, string $outcome)` — section review + +Schema lifecycle events (optional, activity log covers most): +- `FormSchemaPublished(FormSchema $schema)` +- `FormSchemaUnpublished(FormSchema $schema)` +- `FormSchemaTokenRotated(FormSchema $schema)` + +All events implement `ShouldBroadcast` so real-time UI updates are possible +in future iterations. + +### 17.2 Custom field types + +`CustomFieldTypeRegistry` allows extensions without core code changes: + +```php +// config/form_builder.php +'custom_field_types' => [ + 'rich_text' => \App\FormFieldTypes\RichTextFieldHandler::class, + // ... +], +``` + +Each handler implements `CustomFieldTypeHandler` interface: + +```php +interface CustomFieldTypeHandler +{ + public function validateValue(mixed $value): ValidationResult; + public function transformForStorage(mixed $value): array; + public function transformForDisplay(array $storedValue): mixed; + public function getUISchema(): array; // tells frontend how to render + public function supportsFiltering(): bool; + public function getValueStorageHint(): ?string; +} +``` + +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 + +`FormPurpose::CUSTOM` + `form_schemas.custom_purpose_slug` string. +Organisations define their own purposes (e.g., +`brandweertraining_certificering`). + +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) + +### 17.4 Custom validation callbacks + +`form_fields.validation_rules` JSON supports callback references: +```json +{ + "callback": "App\\Services\\Validators\\KvkValidator@validate" +} +``` + +Registered callbacks in `config/form_builder.php`: +```php +'validation_callbacks' => [ + 'kvk_lookup' => \App\Services\Validators\KvkValidator::class, + // ... +], +``` + +Unregistered callbacks rejected at save time. + +### 17.5 Webhooks + +#### 17.5.1 Schema + +See §4.11 `form_schema_webhooks` and §4.12 `form_webhook_deliveries`. + +#### 17.5.2 Dispatcher + +`FormWebhookDispatcher` listens for FormSubmissionSubmitted / Reviewed / +SectionSubmitted / SectionReviewed events. On trigger: +- Finds matching webhooks for the schema +- For each: creates a form_webhook_delivery row with status=pending +- Queues `DeliverFormWebhookJob` per delivery on dedicated `webhooks` queue + +#### 17.5.3 Delivery job + +`DeliverFormWebhookJob` on `webhooks` queue: +- Idempotent (Laravel job with unique ID per delivery) +- Timeout: 30s per attempt +- On execution: + - Builds payload (submission data + trigger_event + schema metadata) + - Signs with HMAC-SHA256 if webhook.secret is set, header: + `X-Crewli-Signature: sha256=...` + - POSTs to webhook.url with 10-second HTTP timeout + - On 2xx: updates delivery status=delivered, delivered_at=now() + - On retriable (5xx, timeout, network): exponential backoff (1m, 5m, + 30m, 2h, 8h). Max 5 attempts. + - On non-retriable (4xx except 408/429): delivery status=failed, logged + - After all retries exhausted: status=dead_letter + +Response body first 1000 chars stored in `response_body_excerpt` for +debugging. + +#### 17.5.4 Security + +URL validation in `FormWebhookDispatcher`: +- Parse URL; reject non-http(s) +- Resolve host; reject private IP ranges (10.x, 172.16–31.x, 192.168.x, + 127.x, 169.254.x — AWS metadata) +- Check allowlist if configured in + `config/form_builder.php.webhooks.allowlist_domains` +- Check blocklist; configurable IP ranges default to private + metadata + +Admin UI shows validation status + last delivery attempt per webhook. + +#### 17.5.5 Webhook payload format + +```json +{ + "event": "form_submission.submitted", + "triggered_at": "2026-04-17T14:23:11Z", + "organisation": { + "id": "01H...", + "name": "...", + "slug": "..." + }, + "schema": { + "id": "01H...", + "purpose": "event_registration", + "slug": "...", + "version": 3 + }, + "submission": { + "id": "01H...", + "subject_type": "person", + "subject_id": "01H...", + "submitted_at": "2026-04-17T14:23:11Z", + "submitted_by_user_id": null, + "values": { + "shirtmaat": "M", + "dieetwensen": ["vegetarisch"], + ... + } + } +} +``` + +PII fields are included in webhook payloads only if subscriber has been +acknowledged (future feature: per-webhook PII opt-in). For v1, PII is +sent — organisers warned during webhook creation. + +--- + +## 18. Consistency & interaction rules + +This section prevents components built in isolation from conflicting. + +### 18.1 Schema evolution mechanisms + +Three orthogonal mechanisms; never use them interchangeably: +- `schema_version` = MARKS edits (always active) +- `schema_snapshot` = BACKS UP state (per schema's snapshot_mode) +- `freeze_on_submit` = PREVENTS edits (after first submission) + +Pairing guidance: +- Low-stakes forms (event_registration): `snapshot_mode=never, freeze_on_submit=false` +- Audited forms (incident_report): `snapshot_mode=on_submit, freeze_on_submit=true` +- Legal/contract forms (signature_contract): `snapshot_mode=on_submit, freeze_on_submit=true` + +### 18.2 Hergebruik mechanisms + +Three forms of reuse; distinct roles: +- `form_templates` = COMPLETE starting point for new schemas (apply → new + schema with copied fields) +- `form_field_library` = SINGLE reusable field definition (insert → new + form_field with reference) +- `schema_snapshot` = FROZEN copy for audit (auto-generated, not reused) + +A template can reference library fields in its schema_snapshot; when the +template is applied, library fields are resolved to current library +definitions at that moment (denormalised into the resulting form_fields). + +### 18.3 Role restrictions vs Pattern C interaction + +Rule: `role_restrictions` gate what OTHER users see. A subject always sees +their own entity-owned or mirrored values. Example: a volunteer sees their +own emergency_contact_phone even if role_restrictions hide it from +organiser_members. + +Validated by FieldAccessService: `canRead(user, field, submission)` returns +true when user.id == submission.subject_id (and subject_type is 'user' or +submission.subject is user's person) regardless of role_restrictions. + +### 18.4 Auto-save vs is_test vs status + +- Auto-save always produces submissions with `is_test=false` and status='draft' +- Test mode (form preview) produces submissions with `is_test=true` — + regardless of auto-save setting +- Status='draft' becomes 'submitted' only on explicit user submit, never + on auto-save + +### 18.5 Events as backbone + +Webhooks, activity log, analytics, emails, and all integrations attach as +LISTENERS on Laravel events. Never fire webhooks or log activity +ad-hoc inside services. Rule: if your service needs to trigger a side +effect on submission state change, add a listener to the relevant event. +This keeps services testable and the event graph discoverable. + +### 18.6 System-internal vs form-rendered reads + +Services that consume profile/person/artist data for internal purposes +(e.g., computing reliability_score, matching identity, sending briefings) +read DIRECTLY from entity tables (user_profiles, persons, etc.). They do +NOT go through form_fields/form_values. + +The form layer is ONLY for: +- Rendering forms to end users (reading bindings to prefill) +- Accepting submissions (writing bindings back) +- Displaying submissions to organisers (rendering stored form_values) + +This rule is enforced by architecture reviews. + +--- + +## 19. Self-hosting principle ("eat your own dog food") + +Wherever possible, internal Crewli forms are expressed through the form +builder itself. This is a test of universality: if our builder can't +produce our own configuration UIs, it's not universal enough. + +Targets in v1: +- Onboarding wizard (FormPurpose::ONBOARDING_WIZARD) and + event_setup_wizard ARE self-hosted from day one — they exist in the + enum and are a test of universality. + +--- + +## 20. Trade-offs & cost awareness + +### 20.1 Performance contract + +Pattern A lookups MUST be as fast as current direct-column lookups. +The form layer is NOT a performance tax on system-internal reads — see +§18.6. Pattern A bindings are documented, but services reading them +consume the entity table directly. + +### 20.2 Testing strategy + +The FormPurpose × pattern × field-type matrix is combinatorially large. +Pragmatic coverage: +- **Integration tests per FormPurpose**: happy path for each of the 22 + purposes, minimum one test each +- **Unit tests per field type**: value transformation on read/write +- **Security invariant tests**: admin_only fields never leak, + role_restrictions enforced, SSRF blocked, binding-change-safeguard triggers +- **Migration rehearsal test**: fixture-based test replicating the §11.2 + script on a seeded dataset +- **Integration contract tests**: per §31 contract, verify forms-to-other- + module handoffs work + +Target: 95%+ coverage on FormSchemaService, FormSubmissionService, +FormValueService, FieldAccessService, FormLocaleResolver, +FormWebhookDispatcher. + +### 20.3 Developer onboarding + +A new developer should be able to: +- Understand "what is a form_schema" in under 10 minutes (TL;DR section) +- Create a new FormPurpose and wire up a schema in under 1 hour (with template) +- Add a custom field_type in under 4 hours (with CustomFieldTypeHandler example) + +Quality gate: during implementation, write a +`/dev-docs/form-builder-getting-started.md` with code examples per common +scenario (new schema, new field type, new webhook, new subject type). + +### 20.4 Migration risk + +Mandatory rehearsal (§11.4) on dev DB copy before real migration. +Comparison script output is part of the migration approval gate. + +--- + +## 21. Pre-flight audit gate + +Every Claude Code session starts with this audit, BEFORE any code changes: + +```bash +# 1. Migration files +find api/database/migrations -name "*volunteer_profile*" -o -name "*user_profile*" + +# 2. Model classes +find api/app/Models -name "VolunteerProfile*" -o -name "UserProfile*" + +# 3. Resources +find api/app/Http/Resources -name "VolunteerProfile*" -o -name "UserProfile*" + +# 4. References +grep -rn "volunteer_profile\|VolunteerProfile\|user_profile\|UserProfile" \ + api/app/ api/database/ --include="*.php" + +# 5. Legacy column leakage +grep -rn "access_requirements\|tshirt_size\|first_aid\|driving_licence" \ + api/database/migrations/ + +# 6. Git history +git log --oneline --all | grep -iE "volunteer.profile|user.profile|volunteer_profiles" + +# 7. Working tree +git status +git diff +``` + +Report findings. Nothing found → proceed. Anything found → STOP, report, +wait for Bert's approval of proposed revert plan. + +Audit is MANDATORY until the refactor completes and ARCH-FORM-BUILDER.md +is marked as "refactor complete". After that, the audit is removed from +session prompts. + +--- + +## 22. Failure-mode resilience + +### 22.1 Idempotency + +Client-generated `idempotency_key` (ULID) in submission submit requests. +Backend: UNIQUE(form_schema_id, idempotency_key) partial where not null. +Duplicate request with same key is a no-op returning the original +submission. + +### 22.2 Auto-save + +When `form_schemas.auto_save_enabled=true`: +- Frontend debounces input at 2s; sends PUT on the draft submission +- Backend endpoint `PUT /form-submissions/{id}/auto-save` accepts partial + payloads (only changed fields) +- Increments `auto_save_count` for debugging +- Does not fire FormSubmissionDraftUpdated event on every auto-save + (rate-limited to one event per 30s per submission) to prevent webhook + storms +- On auto-save failure: client retains local-storage backup and retries. + Max 3 retry attempts with exponential backoff, then UI shows + "Kon niet opslaan — probeer handmatig" warning. + +### 22.3 Binding-change safeguard + +See §6.5. + +### 22.4 Audit trail + +- `form_schemas.created_by_user_id`, `last_updated_by_user_id` +- Activity log on form_schemas, form_fields, form_submissions +- Webhook deliveries logged with payload snapshots + +### 22.5 Webhook security + +See §17.5.4. SSRF prevention, allowlist/blocklist, timeouts. + +### 22.6 File upload security + +For FILE_UPLOAD / IMAGE_UPLOAD field types: +- Server-side MIME type validation (never trust client) +- Default allowed MIMEs in + `config/form_builder.php.file_uploads.default_allowed_mime_types` +- Per-field override via validation_rules.allowed_mime_types +- Size cap default 5MB, per-field override via validation_rules.max_size_mb +- Stored under `{storage_disk}/form-uploads/{submission_id}/{ulid}.{ext}` +- Filename sanitised; original name stored as metadata only + +Storage disk: `config('filesystems.default')` by default. Per-field +override: `validation_rules.storage_disk`. + +### 22.7 Soft limits + +`config/form_builder.php`: +```php +return [ + 'limits' => [ + 'max_fields_per_schema' => 100, + 'max_filterable_fields_per_schema' => 20, + 'max_options_per_field' => 100, + 'max_submissions_per_public_schema_per_ip_per_hour' => 5, + ], + 'webhooks' => [ + 'allowlist_domains' => [], + 'blocklist_ips' => ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', + '192.168.0.0/16', '169.254.169.254/32'], + 'timeout_seconds' => 10, + 'max_attempts' => 5, + ], + 'file_uploads' => [ + 'default_allowed_mime_types' => ['image/jpeg', 'image/png', 'image/webp', + 'application/pdf'], + 'default_max_size_mb' => 5, + ], + 'search_index' => [ + 'max_chars' => 10000, + ], + 'captcha' => [ + 'provider' => 'turnstile', + 'required_for_purposes' => ['public_complaint', 'public_press_request'], + ], + 'public_submitter_ip_retention_days' => 30, + 'user_profile_settings_whitelist' => [ + 'ui.theme', 'ui.sidebar_collapsed', 'ui.time_format', + 'notifications.email_digest', 'notifications.shift_reminders', + 'notifications.event_updates', + ], + 'custom_field_types' => [], + 'validation_callbacks' => [], +]; +``` + +Violations rejected at Form Request validation layer with clear error +messages. + +### 22.8 Destructive operations + +Deleting a schema with submissions requires typed confirmation +(organiser types the schema name). Deleting a field with values requires +same. Confirmation flow implemented in frontend; backend accepts +`?confirmed_name=` query param. + +### 22.9 Admin-only field leak prevention + +Test class `FormResourceSecurityTest` covers: +- For each FormPurpose, a submission is fetched by non-admin user +- Assert admin_only fields are not in the response +- Applied to both list and detail endpoints +- Run as part of CI + +### 22.10 [v1.2] Queue configuration + +Dedicated queues for form-builder jobs: + +```php +// config/queue.php — add +'connections' => [ + 'webhooks' => [ + 'driver' => 'redis', + 'queue' => 'webhooks', + 'retry_after' => 120, + 'block_for' => null, + ], +], +``` + +Jobs and their queues: +- `DeliverFormWebhookJob` → `webhooks` queue (dedicated, can be throttled + separately) +- `FormSubmissionRetentionJob` → `default` queue (daily schedule) +- `BackfillFormValueIndexedJob` → `default` queue (on is_filterable toggle) +- `RebuildSearchIndexJob` → `default` queue (on is_filterable or binding change) + +Frontend event broadcasts (Reverb/Pusher) use their own broadcast queue. + +--- + +## 23. Observability & analytics hooks + +### 23.1 Timestamps for funnel analytics + +- `opened_at` — first GET on the form (or first frontend render) +- `first_interacted_at` — first field interaction (focus or input) +- `submitted_at` — status → submitted +- `submission_duration_seconds` = submitted_at - opened_at (populated on + submit) + +### 23.2 Universal text search + +`form_submissions.search_index` populated by an observer on form_values +save. Concatenates all text-type values of a submission (TEXT, TEXTAREA, +EMAIL, PHONE, URL, SELECT, RADIO option labels) into a space-separated +text field. Indexed with MySQL FULLTEXT. + +Max chars configurable (default 10000). Truncated with ellipsis. + +For drafts: rebuilt on every form_values save. For submissions: frozen at +submit (no further updates unless value changes). + +### 23.3 Activity log depth + +- form_schemas: create, update (label/field additions/removals), delete, + duplicate, version bumps, lock/unlock +- form_fields: create, update (with old/new binding, old/new + is_filterable, old/new role_restrictions), delete +- form_submissions: create, status changes, review changes, delegate, + anonymise, delete +- form_templates: create, update, activate/deactivate +- form_field_library: create, update, activate/deactivate + +Activity log entries for webhook deliveries NOT added (they have their +own audit table `form_webhook_deliveries`). + +### 23.4 [v1.2] Anonymisation audit + +Each anonymised field produces a SEPARATE activity log entry: + +```json +{ + "log_name": "form_value", + "description": "field.anonymised", + "subject_type": "FormValue", + "subject_id": 12345, + "properties": { + "field_slug": "emergency_contact_phone", + "reason": "retention_policy", + "original_was_pii": true + }, + "causer_type": "System", + "causer_id": null +} +``` + +Rationale: field-level audit dichtheid matters for GDPR compliance demos. + +--- + +## 24. Access control per field + +### 24.1 `form_fields.role_restrictions` JSON + +```json +{ + "read": { + "any_of_roles": ["org_admin", "event_manager"] + }, + "write": { + "any_of_roles": ["org_admin"] + } +} +``` + +Operators on roles: +- `any_of_roles`: user needs at least one listed role +- `all_of_roles`: user needs all listed roles +- `not_roles`: user must not have any listed role +- `subject_self`: true → the subject themselves always has access (overrides + other rules) + +If `role_restrictions` is null: defaults to +`{ read: true, write: { any_of_roles: ["org_admin", "event_manager"] } }` +for is_admin_only=false fields, and stricter for is_admin_only=true. + +### 24.2 FieldAccessService + +```php +class FieldAccessService +{ + public function canRead(User $user, FormField $field, FormSubmission $submission): bool; + public function canWrite(User $user, FormField $field, FormSubmission $submission): bool; + public function filterVisibleFields(User $user, Collection $fields, FormSubmission $submission): Collection; +} +``` + +Used by: +- FormResource / FormSubmissionResource when rendering API responses + (hides invisible fields) +- FormValueService when accepting submits (rejects writes to fields user + can't write) +- FilterQueryBuilder when applying filters (rejects filters on invisible + fields with 403) +- Form-builder UI (hides disallowed actions) + +### 24.3 Test coverage + +`FieldAccessServiceTest` and `FormResourceSecurityTest` ensure: +- Per role, correct fields visible/editable +- Subject-self always has access +- Cross-org attempts blocked +- `role_restrictions=null` defaults applied consistently + +--- + +## 25. Self-hosting reservations (FormPurpose slots) + +FormPurpose enum values reserved for future self-hosting (not implemented +in v1.2): +- `schema_editor` — would host the "create/edit form_schema" form as a + form itself +- `field_editor` — would host the "create/edit form_field" form as a form + +When these become implemented, they demonstrate §19 (self-hosting) by +making the builder's own UI a form defined through the builder. + +--- + +## 26. Open questions / deferred decisions + +- **Analytics dashboards** — drop-off detection, A/B testing, completion + rate dashboards. Telemetry client-side required. Deferred. +- **Marketplace templates** — sharing templates between orgs. Deferred. +- **AI-generated schemas** — LLM scaffolding of form definitions. Deferred. +- **Org-specific bindable columns** — orgs adding custom columns on + user_profile and binding to them. Needs a schema-extension subsystem. + Deferred. +- **Bulk-edit values** — "set dietary_preference=null for all persons". + UX-heavy, needs audit. Deferred. +- **Schema-editor self-hosting** — see §25. Deferred to a future phase. +- **Realtime collaborative editing** — multiple editors with live cursors. + Current v1.2 uses pessimistic locks (§14.5 edit_lock_*). Deferred to + later phase with CRDT. +- **PDF export of submissions** — deferred to PDF module. +- **Print layouts** — deferred to PDF module. +- **Per-webhook PII opt-out** — webhooks always receive PII in v1. + Deferred. + +--- + +## 27. Out of scope for this refactor + +- Workflow / approval escalation chains beyond the simple review flow +- Notifications on submit/update beyond core integrations (advanced + notification preferences UI) +- PDF export of submissions (PDF module) +- Rich-text TipTap-based fields (TIPTAP field type comes in a later phase) +- Client-side field-by-field drop-off analytics +- A/B testing of schema variants +- Email bounce verification for public submissions +- Legal signature-verification service (exists as stub in v1.2; UI deferred) + +--- +## 28. [v1.2] User guidance principles + +This section defines the non-negotiable UX standards for form-builder +features. No feature ships without complying with all five principles. + +### 28.1 Every decision-impacting control has contextual help + +Controls that change system behaviour in non-obvious ways must have an +icon-triggered tooltip or inline explanation. Examples: + +- **`is_filterable` toggle** — tooltip: "Dit veld wordt extra geïndexeerd + voor snelle filtering in overzichten. Alleen aanvinken voor velden die je + daadwerkelijk als filter gebruikt." +- **`is_pii` toggle** — tooltip: "Dit veld bevat persoonsgegevens. Bij + retentie-verwerking worden deze waardes geanonimiseerd." +- **`freeze_on_submit` toggle** — tooltip: "Na de eerste ingediende + submissie kunnen de velden niet meer gewijzigd worden. Gebruik dit voor + contracten of audit-kritieke formulieren." +- **`snapshot_mode` dropdown** — per optie een korte uitleg in de + dropdown zelf. +- **`retention_days` input** — tooltip: "Na deze periode worden + PII-velden geanonimiseerd. Vraag bij twijfel je privacy-officer om advies." + +Rule of thumb: **if an organiser has to guess what a toggle does, it's a +bug in the UX**. + +### 28.2 Every destructive action has a preview or confirmation + +Actions that cannot be undone (or are painful to undo) require either: + +- A preview showing consequences before the action, OR +- A typed-confirmation dialog (user types the item name) + +Destructive actions in scope: +- Delete schema with submissions → typed confirmation +- Delete field with values → typed confirmation +- Change field's binding when submissions exist → preview showing + "X submissions will be orphaned" + typed confirmation +- Rotate public_token → preview showing grace period +- Anonymise submission manually → typed confirmation +- Change field_type (changes storage format) → preview + typed confirmation + +Preview structure: +1. What will happen (list) +2. How many records affected +3. Whether action is reversible (and how) +4. Typed confirmation input if irreversible + +### 28.3 Schema preview before publishing + +Organiser can preview a schema at any time: +- As each supported user role (volunteer, organiser admin, etc.) +- In each locale (if translations exist) +- With sample data driving conditional_logic +- On mobile + desktop viewport + +Preview button is prominently visible in form-builder UI, not hidden in a +menu. Published schemas show a "Bekijk live formulier" link in the same +place. + +### 28.4 Onboarding for new features + +Any new major feature (e.g., webhooks, section-level submit, custom +field types) includes onboarding: +- First-time dialog when the feature is accessed, explaining it briefly +- Link to full VitePress documentation +- Dismissible with "Niet meer tonen" checkbox — preference stored in + `user_profiles.settings` + +### 28.5 In-app documentation links + +Every complex screen has a link to the relevant VitePress documentation: +- Form-builder UI → `/docs/organizer/forms/form-builder-overview` +- Webhook configuration → `/docs/organizer/forms/webhooks` +- Retention policies → `/docs/organizer/forms/privacy-and-retention` +- Field library → `/docs/organizer/forms/reusable-fields` +- Section-level submit → `/docs/organizer/forms/advancing-forms` + +Links open in new tab, marked with external-link icon. + +--- + +## 29. [v1.2] Documentation coverage requirements per session + +Every S1-S6 session MUST ship VitePress documentation alongside the code. +Documentation is not an afterthought; it is part of Definition of Done. + +### 29.1 Per-session docs requirement + +Each implementation session delivers documentation covering: +- **User-facing feature** — what the feature is, in organiser's language +- **How-to guide** — step-by-step for the most common use-case +- **Reference** — every configuration option explained +- **Edge cases** — what happens in error conditions + +Session cannot be marked complete until corresponding docs are reviewed +and committed. + +### 29.2 Documentation targets per session + +Mapping of ARCH sections to docs pages (written during S1-S6): + +| ARCH section | VitePress page | Session | +|---|---|---| +| §3 FormPurpose | `/docs/organizer/forms/what-is-a-form-purpose` | S3 | +| §4 Core tables | (developer-only, `/dev-docs/form-builder-getting-started.md`) | S1 | +| §5 FormFieldType | `/docs/organizer/forms/field-types` | S3 | +| §6 Field binding | `/docs/organizer/forms/binding-and-entity-columns` | S3 | +| §7 Filter architecture | `/docs/organizer/forms/filtering-submissions` | S4 | +| §8 Conditional logic | `/docs/organizer/forms/conditional-logic` | S3 | +| §9 Signature | `/docs/organizer/forms/signatures-and-contracts` | S3 | +| §10 Public tokens | `/docs/organizer/forms/public-forms` | S3 | +| §13 Governance | `/docs/organizer/forms/privacy-and-retention` | S2 | +| §14 Schema lifecycle | `/docs/organizer/forms/versioning-and-snapshots` | S2 | +| §15 Workflows | `/docs/organizer/forms/reviews-and-delegation` | S4 | +| §17.5 Webhooks | `/docs/organizer/forms/webhooks` | S5 | + +### 29.3 Migration-specific docs + +Before the data migration runs (end of S1), publish: +- `/docs/organizer/forms/migration-what-changes.md` — user-facing changelog +- `/dev-docs/form-builder-migration-playbook.md` — developer runbook + +### 29.4 Copy catalogue as living document + +`/dev-docs/COPY_CATALOGUE.md` is maintained across all sessions — see §30. + +### 29.5 Quality gate + +Docs reviewer checks: +- No jargon without explanation +- Screenshots of the UI where applicable (Cursor/Claude generates, Bert + validates in manual verification) +- "Als je vastloopt..." troubleshooting section +- Link in in-app help menu (§28.5) + +--- + +## 30. [v1.2] In-app copy catalogue (living seed) + +A single source of truth for user-facing Dutch copy used in form-builder +UI. Stored in `/dev-docs/COPY_CATALOGUE.md` and used as reference for +frontend implementation. + +Rationale: prevents inconsistent terminology ("Dienst" vs "Shift" vs +"Taak") and centralises warnings/tooltips so they can be edited in one +place. + +### 30.1 Naming conventions + +| Concept | Canonical Dutch term | Never use | +|---|---|---| +| `form_schema` | Formulier | Schema, template | +| `form_field` | Veld | Vraag, item | +| `form_template` | Formulier-sjabloon | Template (alleen in dev-docs) | +| `form_field_library` | Veldenbibliotheek | Library, bibliotheek alleen | +| `form_submission` | Inzending | Submission, antwoord | +| `is_filterable` | Filterbaar | Queryable, zoekbaar | +| `is_pii` | Bevat persoonsgegevens | Privacy-gevoelig | +| `freeze_on_submit` | Bevriezen na inzending | Vergrendelen | +| `consent_version` | Toestemmingsversie | Consent-versie | + +### 30.2 Tooltip catalogue (selection) + +``` +is_filterable: + "Filterbaar — dit veld wordt extra geïndexeerd voor snelle filtering + in overzichten. Alleen aanvinken voor velden die je daadwerkelijk als + filter gebruikt (bijvoorbeeld: shirtmaat wel, motivatie niet)." + +is_pii: + "Bevat persoonsgegevens — bij retentie-verwerking worden deze waardes + geanonimiseerd volgens je privacy-instellingen. Vink aan voor velden + zoals telefoon, e-mail, noodcontact, medische info." + +is_unique: + "Uniek per formulier — waardes van dit veld moeten uniek zijn over alle + inzendingen heen. Geschikt voor bijvoorbeeld BSN of werknemersnummer. + Dubbele waardes worden afgewezen." + +freeze_on_submit: + "Bevriezen na eerste inzending — zodra iemand het formulier indient + kunnen de velden niet meer gewijzigd worden. Gebruik dit voor + contracten, signatures, of formulieren waar de structuur vast moet + staan voor audit-doeleinden." + +snapshot_mode: + never: "Geen snapshot — wijzigingen worden alleen in het activity log + bijgehouden." + on_submit: "Snapshot bij inzending — bij elke indiening wordt het + complete formulier gesnapshot voor audit-doeleinden." + always: "Altijd snapshot — elke wijziging (ook drafts) wordt + gesnapshot. Gebruikt meer opslag maar biedt het volledige + audit-spoor." + +retention_days: + "Bewaartermijn — na deze periode (vanaf inzendingsdatum) worden + PII-velden automatisch geanonimiseerd. Typische waardes: 1095 dagen + (3 jaar) voor vrijwilligers, 2555 dagen (7 jaar) voor contracten, + null voor onbeperkt bewaren." +``` + +### 30.3 Warning catalogue (selection) + +``` +binding_change_with_submissions: + "Je staat op het punt de koppeling van dit veld te wijzigen terwijl er + al {count} ingediende inzendingen zijn. De historische waardes blijven + bestaan, maar zijn niet meer de bron-van-waarheid. Dit kan niet + ongedaan worden gemaakt." + +delete_schema_with_submissions: + "Dit formulier heeft {count} inzendingen. Als je het verwijdert, blijven + de inzendingen bewaard als archief maar zijn niet meer nieuw in te + dienen. Type de naam van het formulier om te bevestigen:" + +field_type_change: + "Je wijzigt het veldtype van {old} naar {new}. Bestaande waardes worden + mogelijk niet correct omgezet — sommige kunnen onleesbaar worden. + Aanbevolen: maak een nieuw veld aan in plaats van dit veld te + wijzigen." + +public_token_rotation: + "Je roteert de publieke link voor dit formulier. Bestaande gebruikers + kunnen nog 7 dagen inzenden met de oude link; daarna krijgen ze een + 410 Gone foutmelding." +``` + +### 30.4 Maintenance + +Copy catalogue is updated in every session that adds new UI: +- Check existing terms before creating new +- Propose new terms to COPY_CATALOGUE.md +- Bert reviews before merge + +--- + +## 31. [v1.2] Integration contracts + +The form builder does not operate in isolation. Other modules produce or +consume form submissions, and these interactions must be contract-defined +to prevent ad-hoc coupling. + +### 31.1 Person Identity Matching integration + +**Trigger:** `FormSubmissionSubmitted` event where +`form_schema.purpose == 'event_registration'` and submission is public OR +the submitter's user_id is not already linked to a person. + +**Listener:** `TriggerIdentityMatchOnRegistration` + +**Contract:** +``` +Input: FormSubmission with subject_type=person (or null for public pre- + submission). +Behaviour: Call PersonIdentityService::detectMatches($person) per + existing logic. Service creates person_identity_matches rows + with status=pending. + Does NOT auto-confirm. Auto-linking is explicitly forbidden — + organiser must confirm. +Output: No direct return. Side-effect: potential person_identity_matches + rows. +``` + +**Failure mode:** if PersonIdentityService throws, listener logs at error +level and does NOT fail the FormSubmission event propagation (other +listeners continue). + +### 31.2 GDPR delete workflow integration + +**Trigger:** User account deletion (existing flow in UserController::destroy +or dedicated GDPR-delete endpoint). + +**Contract:** +``` +Input: User about to be deleted. +Behaviour (in order): + 1. For every form_submission where subject_type='user' AND + subject_id=that_user: call FormSubmissionAnonymisationService::anonymise + 2. For every form_submission where submitted_by_user_id=that_user + AND subject_type != 'user' (i.e., user submitted for others or anonymous): + clear submitted_by_user_id → null; copy user.name/email to + public_submitter_name/public_submitter_email; set + public_submitter_ip_anonymised_at = now(). + 3. Delete user_profiles row (cascade via FK on user_id). + 4. Delete user_organisation_tags rows for this user. + 5. Anonymise activity_log entries where causer_id=that_user: replace + causer_name with "[deleted-user]" but preserve log integrity. +Output: User fully disconnected from form-related data while audit trail + preserved. +``` + +**Transactional:** all steps wrapped in DB transaction. If any step fails, +rollback; user deletion is also rolled back. + +**GDPR export endpoint:** +``` +GET /api/v1/users/{user}/gdpr-export + +Response: 200 OK with Content-Type: application/json +{ + "user": { ... core user attributes ... }, + "user_profile": { ... user_profiles row ... }, + "tags": [ ... user_organisation_tags ... ], + "submissions": [ + { + "schema": { "slug", "purpose", "organisation_name" }, + "values": { ... form_values keyed by field slug ... }, + "submitted_at": "...", + "status": "..." + } + ] +} +``` + +Access: user themselves OR org_admin of any org the user is a member of. +Format: JSON (v1). PDF export deferred. + +### 31.3 Shift assignments integration + +**Trigger:** `FormSubmissionSubmitted` where schema purpose is +`event_registration` AND submission contains AVAILABILITY_PICKER field +values. + +**Listener:** `CreateProvisionalShiftAssignmentsFromRegistration` + +**Contract:** +``` +Input: FormSubmission with subject_type=person, AVAILABILITY_PICKER + values (array of time_slot_id ULIDs), and optionally SECTION_PRIORITY + values. +Behaviour: + 1. For each time_slot_id in AVAILABILITY_PICKER value: + - Find shifts within that time_slot that match person's crowd_type + - If person has section priorities: prefer shifts in priority sections + - Create ShiftAssignment with status='claim_pending' + - Do NOT auto-confirm; requires organiser approval + 2. For each SECTION_PRIORITY value: upsert row in + person_section_preferences (existing table). +Output: ShiftAssignments in claim_pending status. Organiser sees them in + the standard shift-assignments approval queue. +``` + +**Cancellation flow:** when a person submits an `absence_report` form +(§3.2.13), the listener `HandleAbsenceReport` flips affected +ShiftAssignment.status → cancelled with +cancellation_source='volunteer_absence'. + +### 31.4 Email notifications integration + +Form-builder uses the existing CrewliMailable + email-template +infrastructure (built April 2026). + +**Mailables used:** + +| Event | Mailable | Template | +|---|---|---| +| FormSubmissionSubmitted (event_registration) | `RegistrationConfirmation` | `registration_confirmation` | +| FormSubmissionReviewed (approved) | `SubmissionApproved` | `submission_approved` | +| FormSubmissionReviewed (rejected/changes_requested) | `SubmissionNeedsAttention` | `submission_needs_attention` | +| FormSubmissionSubmitted (pending review) | `ReviewPending` | `review_pending` | +| Retention job run | `RetentionReport` (aggregated, daily) | `retention_report` | +| Incident report (kritiek ernst) | `CriticalIncidentAlert` | `critical_incident_alert` | +| Post-event evaluation trigger | `EvaluationInvitation` | `evaluation_invitation` | + +**Contract:** +``` +All listeners use Mail::queue (not Mail::send) to avoid blocking the +event dispatch. Mailables respect org-level branding config (logo, +primary_color, sender_name, reply_to, footer_text). +``` + +**Template override:** orgs can override default templates via existing +org-level email-template management UI. + +### 31.5 Code-of-conduct gating integration + +**Trigger:** any shift-claim attempt (existing endpoint +`POST /portal/shifts/{shift}/claim`). + +**Check:** before accepting the claim, verify: +``` +Does a form_submission exist where + form_schema.purpose = 'signature_code_of_conduct' AND + subject_type = 'person' AND + subject_id = claimer_person_id AND + status = 'submitted' AND + form_schema.organisation_id = current_organisation_id ? +``` + +If NO: reject claim with 422 "Je moet eerst de gedragscode tekenen". +Response includes link to the code-of-conduct form URL. + +If YES: accept claim. + +Implementation: in `ShiftClaimService::canClaim()`. + +### 31.6 Supplier intake / production_requests integration + +**Trigger:** organiser creates a `production_requests` row (existing +infrastructure) with `company_id` and selects a form_schema of purpose +`supplier_intake`. + +**Behaviour:** +- form_schema's public_token is created/reused +- production_request.token is generated (existing logic) +- Supplier receives email with URL combining both tokens: + `{APP_URL}/f/{public_token}?pr={production_request.token}` +- When supplier opens URL: both tokens validated, submission created with + subject_type=company, subject_id=production_request.company_id + +### 31.7 Accreditation engine integration (ARCH-07, future) + +Reserved contract for when accreditation engine lands: + +- When `signature_receipt` submissions are created: attached accreditation + items from ARCH-07's `accreditation_items` are listed in the submission + as pre-filled TABLE_ROWS values +- When `check_out_inventory` submissions are reviewed as complete: + accreditation_item.returned_at is updated per item + +Exact contract TBD when ARCH-07 implementation starts. v1.2 leaves the +hooks in place (purpose enum values exist, no code integration yet). + +### 31.8 Crowd list integration + +**Trigger:** `FormSubmissionSubmitted` where purpose=event_registration +AND submission results in a new Person being created. + +**Listener:** `AddPersonToApplicableCrowdListsOnRegistration` + +**Contract:** +``` +Input: FormSubmission with subject_type=person (new person). +Behaviour: + 1. Fetch crowd_lists where organisation_id matches AND + crowd_list.auto_add_criteria matches the new person (e.g., + crowd_type matches). + 2. For each matching crowd_list: add person via existing + CrowdListService::addPerson. + 3. Crowd_lists without auto_add_criteria are ignored (manual + management). +Output: person_crowd_list pivot rows for applicable lists. +``` + +**Crewli Context:** Crowd lists are optional organizational tools, not +mandatory gatekeepers. Not every registration needs a crowd list +membership. + +### 31.9 Integration contract tests + +Every contract in §31.1-31.8 has a corresponding integration test in +`tests/Feature/FormBuilder/Integration/`: + +- `IdentityMatchTriggerTest` +- `GdprDeleteCascadeTest` +- `ShiftAssignmentFromRegistrationTest` +- `EmailNotificationFlowTest` +- `CodeOfConductGatingTest` +- `SupplierIntakeFlowTest` +- `CrowdListAutoAddTest` + +Tests are part of CI. Contract changes require test updates (and this +ARCH section update) before merge. + +--- + +## End of ARCH v1.2 + +Refactor completion checklist (updated at end of S6): +- [ ] All migrations applied in dev +- [ ] All 431 existing tests green + ≥200 new tests +- [ ] Migration rehearsal passed +- [ ] Real migration run successful +- [ ] All 22 FormPurposes seed-tested +- [ ] All integration contracts (§31) tested +- [ ] VitePress documentation complete per §29 +- [ ] SCHEMA.md updated to reflect final state +- [ ] Pre-flight audit removed from session prompts +- [ ] This document marked "refactor complete" + +When complete: add a "Refactor complete — effective {DATE}" banner at +top of this document. Pre-flight audit gate (§21) becomes optional.