3000 lines
112 KiB
Markdown
3000 lines
112 KiB
Markdown
# 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=<n>` 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.
|
||
|
||
### 31.10 Tag sync integration (BACKLOG FORM-02)
|
||
|
||
Replaces the S1-era `TagSyncService` that read the legacy
|
||
`person_field_values` table. Purged in S2a; rebuilt in S2b/S2c against
|
||
the new FormBuilder.
|
||
|
||
**Trigger:** `FormSubmissionSubmitted` event (ARCH §17.1) OR explicit
|
||
call from `PersonController::approve()` after status transitions to
|
||
`approved`.
|
||
|
||
**Listener:** `SyncTagPickerValuesToUserTagsListener` — for the given
|
||
submission, finds all `form_values` whose field has
|
||
`field_type=TAG_PICKER`, and upserts rows into `user_organisation_tags`
|
||
with `source=self_reported`, respecting `person.user_id` (skip if null).
|
||
Only syncs to the subject person's user account.
|
||
|
||
**Failure mode:** log at warning level; never throws into the submission
|
||
lifecycle. Reason: a tag-sync failure must not block registration.
|
||
|
||
**Call site removed in S2a:** `PersonController::approve()` and
|
||
`PersonIdentityService::syncRegistrationTags()` used to call
|
||
`TagSyncService::syncFromRegistration($person)` directly. The rebuilt
|
||
flow is listener-driven — no direct service injection required.
|
||
|
||
---
|
||
|
||
## 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.
|