docs: registration form fields, section preferences & form redesign

Update SCHEMA.md (v1.8), design-document.md (v1.9), and API.md with
EAV system for dynamic event-specific registration fields, section
preferences, tag picker sync architecture, and field templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 21:42:36 +02:00
parent 2102a35688
commit fcff3b0344
3 changed files with 385 additions and 22 deletions

View File

@@ -1,7 +1,7 @@
# Crewli — Core Database Schema
> Source: Design Document v1.3 — Section 3.5
> **Version: 1.7** — Updated April 2026
> **Version: 1.8** — Updated April 2026
>
> **Changelog:**
>
@@ -16,6 +16,16 @@
> `recurrence_exceptions` to `events`. Added `event_person_activations`
> pivot. Changed `persons.event_id` to reference festival-level event.
> Added `event_type_label` for UI terminology customisation.
> - v1.8: Registration Form Fields module — EAV system for dynamic event-specific
> registration fields, replacing queryable use of `persons.custom_fields` JSON.
> Added tables: `registration_form_fields`, `person_field_values`,
> `person_section_preferences`. Added columns: `persons.remarks`,
> `persons.date_of_birth`, `events.registration_show_section_preferences`,
> `events.registration_show_availability`. Added TAG_PICKER field type for
> tag selection during registration with deferred sync via TagSyncService.
> Removed minimum volunteer hours threshold concept. Removed hardcoded
> motivation form step. Moved payment status from fixed admin field to
> dynamic registration field.
---
@@ -37,13 +47,14 @@
3. [3.5.3 Festival Sections, Time Slots & Shifts](#353-festival-sections-time-slots--shifts)
4. [3.5.4 Volunteer Profile & History](#354-volunteer-profile--history)
5. [3.5.5 Crowd Types, Persons & Crowd Lists](#355-crowd-types-persons--crowd-lists)
6. [3.5.5b Person Identity Matching](#355b-person-identity-matching)
7. [3.5.6 Accreditation Engine](#356-accreditation-engine)
7. [3.5.7 Artists & Advancing](#357-artists--advancing)
8. [3.5.8 Communication & Briefings](#358-communication--briefings)
9. [3.5.9 Forms, Check-In & Operational](#359-forms-check-in--operational)
10. [3.5.10 Person Tags & Skills](#3510-person-tags--skills)
11. [3.5.11 Database Design Rules & Index Strategy](#3511-database-design-rules--index-strategy)
6. [3.5.5a Person Tags & Skills](#355a-person-tags--skills)
7. [3.5.5b Registration Form Fields & Section Preferences](#355b-registration-form-fields--section-preferences)
8. [3.5.5c Person Identity Matching](#355c-person-identity-matching)
9. [3.5.6 Accreditation Engine](#356-accreditation-engine)
10. [3.5.7 Artists & Advancing](#357-artists--advancing)
11. [3.5.8 Communication & Briefings](#358-communication--briefings)
12. [3.5.9 Forms, Check-In & Operational](#359-forms-check-in--operational)
13. [3.5.11 Database Design Rules & Index Strategy](#3511-database-design-rules--index-strategy)
---
@@ -152,6 +163,8 @@
| `is_recurring` | bool | **v1.7** default: false. True = generated from recurrence rule |
| `recurrence_rule` | string nullable | **v1.7** RRULE (RFC 5545): "FREQ=WEEKLY;BYDAY=SA,SU;UNTIL=20270126" |
| `recurrence_exceptions` | JSON nullable | **v1.7** Array of {date, type: cancelled\|modified, overrides: {}}. JSON OK: opaque config |
| `registration_show_section_preferences` | bool | **v1.8** Default: true. Toggle section preferences step in registration form |
| `registration_show_availability` | bool | **v1.8** Default: true. Toggle time slot availability step in registration form |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:**
@@ -668,8 +681,9 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
| `phone` | string nullable | |
| `status` | enum | `invited\|applied\|pending\|approved\|rejected\|no_show` |
| `is_blacklisted` | bool | |
| `admin_notes` | text nullable | |
| `custom_fields` | JSON | Event-specific fields — not queryable |
| `admin_notes` | text nullable | Organiser-only notes |
| `remarks` | text nullable | **v1.8** Volunteer-editable notes (distinct from admin_notes which is organiser-only) |
| `custom_fields` | JSON | Backward compat + truly opaque event-specific data. For queryable registration data, use `person_field_values` via `registration_form_fields` instead. |
| `deleted_at` | timestamp nullable | Soft delete |
**Unique constraint:** `UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL`
@@ -753,7 +767,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
---
## 3.5.5b Person Identity Matching
## 3.5.5c Person Identity Matching
> **v1.8:** Enterprise-grade identity resolution with three steps: detect → suggest → confirm.
> No silent auto-linking. When a person is created with an email matching an existing user,
@@ -1361,7 +1375,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
---
## 3.5.10 Person Tags & Skills
## 3.5.5a Person Tags & Skills
> Tag-based skills/competencies system for volunteers and crew.
> Tags are defined per organisation, assigned to users at the organisation level
@@ -1422,6 +1436,183 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
---
## 3.5.5b Registration Form Fields & Section Preferences
### `registration_form_fields`
> Event-level dynamic field definitions for registration forms.
> Replaces the need for queryable data in `persons.custom_fields` JSON.
> Organisers configure these per event to collect additional information
> during volunteer/crew registration (shirt size, dietary needs,
> compensation preference, consent, emergency contact, etc.)
>
> Special field type TAG_PICKER: renders the organisation's person_tags
> as selectable options. Answers are stored in person_field_values as
> tag IDs. When the person gets a user_id (account creation or identity
> matching), TagSyncService syncs the selections to user_organisation_tags
> with source=self_reported.
| Column | Type | Notes |
| ------------------ | ------------------ | -------------------------------------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `event_id` | ULID FK | → events (festival-level for festivals) |
| `label` | string | Display label, e.g. "Heb je voedselallergiëen?" |
| `slug` | string(100) | Auto-generated from label, used as stable key |
| `field_type` | enum | `text\|textarea\|select\|multiselect\|checkbox\|radio\|boolean\|number\|tag_picker` |
| `options` | JSON nullable | For select/multiselect/radio/checkbox: array of option strings. NULL for tag_picker (options come from person_tags). JSON OK: opaque config. |
| `tag_category` | string(50) null | Only for tag_picker: filter tags by this category. NULL = show all active tags. |
| `is_required` | bool | Field must be filled in |
| `is_portal_visible`| bool | Shown to person in registration form |
| `is_admin_only` | bool | Only visible in organiser backend |
| `is_filterable` | bool | Available as filter in person list / shift assignment |
| `section` | string(100) null | Form section grouping (e.g. "Vergoeding", "Toestemming") |
| `help_text` | text nullable | Explanatory text shown below the field |
| `sort_order` | int | Display order in form |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
**Unique constraint:** `UNIQUE(event_id, slug)`
**Indexes:** `(event_id, sort_order)`, `(event_id, is_portal_visible, sort_order)`
**No soft delete** — deactivation by deleting the field; existing answers remain for history.
Design notes:
- `options` JSON is acceptable here: it's opaque configuration (the list of choices),
not queryable data. The queryable answers are stored in `person_field_values`.
- `slug` enables stable references across API calls and form submissions even if
`label` changes.
- Fields scoped to event level. For festivals, `event_id` = parent festival
(matching `persons.event_id`).
- `tag_picker` fields do NOT use `options` — available choices come from
`person_tags` filtered by `tag_category` (or all active tags if null).
---
### `person_field_values`
> Stores each person's answers to registration form fields.
> One row per person per field. Queryable via standard SQL.
| Column | Type | Notes |
| ----------------------------- | ------------- | ----------------------------------------------------- |
| `id` | int AI | PK — high volume, pivot-like |
| `person_id` | ULID FK | → persons |
| `registration_form_field_id` | ULID FK | → registration_form_fields |
| `value` | text nullable | For text/textarea/select/radio/boolean/number |
| `selected_options` | JSON nullable | For multiselect/checkbox: array of selected option strings. For tag_picker: array of person_tag_id ULIDs. |
**Unique constraint:** `UNIQUE(person_id, registration_form_field_id)`
**Indexes:** `(registration_form_field_id, value(191))` — for filtering on field values
**No soft delete** — immutable answers. If field definition is deleted, answers remain.
Design notes:
- `selected_options` JSON is used ONLY for multiselect/checkbox/tag_picker fields
where multiple values must be stored. For single-value fields, use `value` only.
- Filtering on multiselect: use MySQL `JSON_CONTAINS()`. Acceptable because
multiselect filtering is a low-frequency organiser query, not a hot path.
- Integer PK for join performance (high volume table).
- For `tag_picker` fields: `selected_options` contains person_tag_id ULIDs,
not tag names. This ensures referential integrity.
---
### `person_section_preferences`
> Volunteer's preferred sections for shift assignment. Soft hints for the
> organiser, NOT promises. The organiser retains full flexibility to assign
> anyone anywhere. Captured during registration (configurable per event
> via `events.registration_show_section_preferences`).
| Column | Type | Notes |
| --------------------- | ------- | --------------------------- |
| `id` | int AI | PK — pivot table |
| `person_id` | ULID FK | → persons |
| `festival_section_id` | ULID FK | → festival_sections |
| `priority` | tinyint | 1 (first choice) 5 |
**Unique constraint:** `UNIQUE(person_id, festival_section_id)`
**Indexes:** `(festival_section_id, priority)`, `(person_id)`
Design notes:
- Priority is a ranking, not a score. 1 = first choice.
- Accompanying text in form: "We proberen hier zoveel mogelijk rekening mee te
houden, maar de uiteindelijke indeling wordt bepaald door de organisatie."
- For festivals: shown sections = sub-event sections + parent's cross_event sections.
- Only shown when `events.registration_show_section_preferences = true`.
---
### Tag Sync Architecture
> When a `tag_picker` registration field is used, tag selections are stored
> in `person_field_values` as person_tag_id ULIDs. These must be synced to
> `user_organisation_tags` when the person gets a `user_id`.
**Service:** `TagSyncService::syncFromRegistration(Person $person): void`
Single responsibility: reads tag_picker field values for this person, syncs
them to `user_organisation_tags` with `source = self_reported`. Uses the
existing sync behaviour: replaces only `self_reported` tags, never touches
`organiser_assigned` tags.
**Trigger points (callers):**
1. `RegistrationFormFieldService::upsertPersonValues()` — if person already has user_id
2. `PersonService::approve()` — when account is created and user_id is set
3. `PersonIdentityService::confirmMatch()` — when user_id is linked via identity matching
**Idempotent:** Safe to call multiple times. If tags already exist, no action.
If self_reported tags were removed by organiser, they are re-created from the
latest registration data (the volunteer still claims them).
---
### `registration_field_templates`
> Organisation-level reusable field templates. Pre-populated with system
> defaults when an organisation is created (same pattern as crowd_types).
> Organisers can customize system templates and add their own.
> When adding a field to an event's registration form, the organiser picks
> from templates — a COPY is created as a registration_form_field on the event.
> The event field is independent; changes don't propagate back to the template.
| Column | Type | Notes |
| ------------------ | ------------------ | -------------------------------------------------- |
| `id` | ULID | PK, `HasUlids` trait |
| `organisation_id` | ULID FK | → organisations |
| `label` | string | e.g. "Shirtmaat" |
| `slug` | string(100) | Auto-generated from label |
| `field_type` | enum | Same RegistrationFieldType enum |
| `options` | JSON nullable | Predefined choices for select/multiselect/etc. |
| `tag_category` | string(50) null | Only for tag_picker |
| `is_required` | bool | Suggested default when creating event field |
| `is_filterable` | bool | Suggested default |
| `is_portal_visible`| bool | Suggested default |
| `is_admin_only` | bool | Suggested default |
| `section` | string(100) null | Suggested form section |
| `help_text` | text nullable | Suggested help text |
| `sort_order` | int | |
| `is_system` | bool | true = shipped with Crewli, false = org-created |
| `is_active` | bool | Deactivate without deleting |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
**Unique constraint:** `UNIQUE(organisation_id, slug)`
**Indexes:** `(organisation_id, is_active, sort_order)`
**No soft delete** — deactivation via `is_active = false`
Design notes:
- Follows the same pattern as `crowd_types`: org-level definitions, seeded
with system defaults on organisation creation.
- System templates (`is_system = true`) can be customized per org (label,
options, etc.) but cannot be deleted — only deactivated.
- Org-created templates (`is_system = false`) can be fully deleted.
- No FK from `registration_form_fields` to templates — the copy is independent.
- System templates seeded: Shirtmaat, Dieetwensen, Vergoeding, Toestemming
gegevensverwerking, Noodcontact naam, Noodcontact telefoon, EHBO/BHV,
Rijbewijs, Eerder vrijwilliger geweest, Certificaten & vaardigheden
(tag_picker), Opmerkingen.
---
## 3.5.11 Database Design Rules & Index Strategy
### Rule 1 — ULID as Primary Key
@@ -1464,6 +1655,10 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
| `shift_waitlist` | `(shift_id, position)` |
| `performances` | `(stage_id, date, start_time, end_time)` |
| `advance_sections` | `(artist_id, is_open)`, `(artist_id, submission_status)` |
| `registration_form_fields` | `UNIQUE(event_id, slug)`, `(event_id, sort_order)` |
| `person_field_values` | `UNIQUE(person_id, registration_form_field_id)`, `(registration_form_field_id, value(191))` |
| `person_section_preferences` | `UNIQUE(person_id, festival_section_id)`, `(festival_section_id, priority)` |
| `registration_field_templates` | `UNIQUE(organisation_id, slug)`, `(organisation_id, is_active, sort_order)` |
---