diff --git a/dev-docs/API.md b/dev-docs/API.md index 3a4aecf3..5a00735a 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -499,4 +499,89 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al - `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid. - `GET /portal/me` — auth:sanctum. Returns the authenticated user's person record for a given event. Query param: `event_id` (required, ULID). Resolves sub-events to parent festival. Returns `PersonResource` with crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found. +## Registration Field Templates (Organisation Settings) + +- `GET /organisations/{org}/registration-field-templates` — list active templates (ordered) +- `POST /organisations/{org}/registration-field-templates` — create template +- `PUT /organisations/{org}/registration-field-templates/{template}` — update template +- `DELETE /organisations/{org}/registration-field-templates/{template}` — delete (org-created) or deactivate (system) + +> Templates: organisation-level reusable field definitions. System templates +> are seeded on org creation. Org-admins can customize and add their own. + +## Registration Form Fields (Event Settings) + +- `GET /events/{event}/registration-fields` — list all fields (ordered by sort_order) +- `POST /events/{event}/registration-fields` — create field (manually or from template) +- `POST /events/{event}/registration-fields/from-template` — create field from template +- `PUT /events/{event}/registration-fields/{field}` — update field +- `DELETE /events/{event}/registration-fields/{field}` — delete field definition (answers preserved) +- `POST /events/{event}/registration-fields/reorder` — bulk reorder +- `POST /events/{event}/registration-fields/import-from-event` — copy fields from another event + +### From-Template Body + +```json +{ "template_id": "ulid" } +``` + +Creates a COPY of the template as an event field. The copy is independent — changes don't propagate back to the template. + +### Import Body + +```json +{ "source_event_id": "ulid" } +``` + +Copies all `registration_form_fields` from the source event. Source must belong to the same organisation. Existing fields on the target event are kept. + +### Tag Picker Fields + +For `tag_picker` fields: the API response includes `available_tags` array (from `person_tags`, filtered by `tag_category` if set) so the frontend knows which tags to render as options. + +## Person Field Values + +- `GET /events/{event}/persons/{person}/field-values` — all answers for a person +- `PUT /events/{event}/persons/{person}/field-values` — bulk upsert answers + +### Bulk Upsert Body + +```json +{ + "values": { + "field_slug": "value_or_array", + "shirtmaat": "L", + "dieetwensen": ["Vegetarisch", "Glutenvrij"], + "certificaten": ["01JXYZ...", "01JABC..."] + } +} +``` + +Replaces all field values for this person in one request. Used by both the registration form and the organiser backend. For `tag_picker` fields: values are arrays of `person_tag_id` ULIDs. If person has a `user_id`, tag sync is triggered automatically. + +## Person Section Preferences + +- `GET /events/{event}/persons/{person}/section-preferences` — list preferences +- `PUT /events/{event}/persons/{person}/section-preferences` — replace all preferences + +### Replace Body + +```json +{ + "preferences": [ + { "festival_section_id": "01JXYZ...", "priority": 1 }, + { "festival_section_id": "01JABC...", "priority": 2 }, + { "festival_section_id": "01JDEF...", "priority": 3 } + ] +} +``` + +## Person List Filtering (extended) + +Additional filter parameters on `GET /events/{event}/persons`: + +- `?field[slug]=value` — filter by registration field value (exact match for single-value, `JSON_CONTAINS` for multiselect) +- `?section_preference={section_id}` — filter by section preference (has this section as any priority) +- `?has_preference=true` — only persons who submitted section preferences + _(Extend this contract per module as endpoints are implemented.)_ diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index a1bb361f..44e0cd80 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -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)` | --- diff --git a/dev-docs/design-document.md b/dev-docs/design-document.md index e1529942..4ec5523e 100644 --- a/dev-docs/design-document.md +++ b/dev-docs/design-document.md @@ -4,7 +4,7 @@ Product Design & Technical Specification Full Stack SaaS — Event & Festival Management Platform -**Version:** 1.8 | **Datum:** April 2026 | **Status:** Concept | **Tech Stack:** Laravel 12 + Vue 3 +**Version:** 1.9 | **Datum:** April 2026 | **Status:** Concept | **Tech Stack:** Laravel 12 + Vue 3 # 1. Product Vision & Scope @@ -329,7 +329,7 @@ Onderstaand het volledige, productie-waardige datamodel. Alle 12 bevindingen uit | **organisations** | id (ULID), name, slug, billing_status, settings (JSON: display prefs only), created_at, deleted_at | hasMany events, crowd_types, accreditation_categories. settings JSON alleen voor opaque UI-config, niet voor queryable data. Soft delete: ja. | | **organisation_user** | id (int AI), user_id, organisation_id, role | Pivot. Integer PK voor join-performance. FK: users, organisations. Spatie role via pivot. | | **user_invitations** | id (ULID), email, invited_by_user_id, organisation_id, event_id (nullable), role, token (ULID, unique), status (pending\|accepted\|expired), expires_at | Token in uitnodigingsmail. Bij accept: zoek bestaand account op email of maak nieuw aan. INDEX: (token), (email, status). | -| **events** | id (ULID), organisation_id, parent_event_id (ULID FK nullable → events, nullOnDelete), name, slug, start_date, end_date, timezone, status (draft\|published\|registration_open\|buildup\|showday\|teardown\|closed), event_type (enum: event\|festival\|series, default: event), event_type_label (string nullable), sub_event_label (string nullable), is_recurring (bool, default: false), recurrence_rule (string nullable), recurrence_exceptions (JSON nullable), deleted_at | belongsTo organisation. belongsTo event as parent (parent_event_id). hasMany events as children (parent_event_id). hasMany festival_sections, time_slots, persons, artists, briefings. Soft delete: ja. INDEX: (organisation_id, status), (parent_event_id), UNIQUE(organisation_id, slug). Zie sectie 3.4.1 voor event type model. | +| **events** | id (ULID), organisation_id, parent_event_id (ULID FK nullable → events, nullOnDelete), name, slug, start_date, end_date, timezone, status (draft\|published\|registration_open\|buildup\|showday\|teardown\|closed), event_type (enum: event\|festival\|series, default: event), event_type_label (string nullable), sub_event_label (string nullable), is_recurring (bool, default: false), recurrence_rule (string nullable), recurrence_exceptions (JSON nullable), registration_show_section_preferences (bool, default: true — v1.9), registration_show_availability (bool, default: true — v1.9), deleted_at | belongsTo organisation. belongsTo event as parent (parent_event_id). hasMany events as children (parent_event_id). hasMany festival_sections, time_slots, persons, artists, briefings. Soft delete: ja. INDEX: (organisation_id, status), (parent_event_id), UNIQUE(organisation_id, slug). Zie sectie 3.4.1 voor event type model. | | **event_user_roles** | id (int AI), user_id, event_id, role | Pivot. Integer PK. FK: users, events. | ### 3.5.2 Locaties (nieuw — oplossing probleem 3) @@ -371,10 +371,13 @@ Oplossing probleem 1 (identiteitsfragmentatie): persons krijgt user_id (nullable | **Tabel** | **Belangrijkste kolommen** | **Relaties, constraints & opmerkingen** | |----|----|----| | **crowd_types** | id (ULID), organisation_id, name, system_type (CREW\|GUEST\|ARTIST\|VOLUNTEER\|PRESS\|PARTNER\|SUPPLIER), color, icon, is_active | Org-level configuratie. INDEX: (organisation_id, system_type). | -| **persons** | id (ULID), user_id (nullable FK users), event_id, crowd_type_id, company_id (nullable), name, email, phone, status (invited\|applied\|pending\|approved\|rejected\|no_show), is_blacklisted, admin_notes, custom_fields (JSON), deleted_at | user_id nullable: externe gasten/artiesten hebben geen platform-account. UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL. INDEX: (event_id, crowd_type_id, status), (email, event_id), (user_id, event_id). custom_fields JSON OK: event-specifieke velden, niet queryable. Soft delete: ja. | +| **persons** | id (ULID), user_id (nullable FK users), event_id, crowd_type_id, company_id (nullable), first_name, last_name, date_of_birth (date nullable — v1.9), email, phone, status (invited\|applied\|pending\|approved\|rejected\|no_show), is_blacklisted, admin_notes, remarks (text nullable — v1.9, vrijwilliger-bewerkbaar), custom_fields (JSON), deleted_at | user_id nullable: externe gasten/artiesten hebben geen platform-account. UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL. INDEX: (event_id, crowd_type_id, status), (email, event_id), (user_id, event_id). custom_fields JSON: backward compat + opaque data; voor queryable registratiedata gebruik `person_field_values`. Soft delete: ja. | | **companies** | id (ULID), organisation_id, name, type (supplier\|partner\|agency\|venue\|other), contact_name, contact_email, contact_phone, deleted_at | Gedeeld over events binnen org. Soft delete: ja. INDEX: (organisation_id). | | **crowd_lists** | id (ULID), event_id, crowd_type_id, name, type (internal\|external), recipient_company_id (nullable), auto_approve (bool), max_persons (int nullable) | hasMany persons via crowd_list_persons pivot. INDEX: (event_id, type). | | **crowd_list_persons** | id (int AI), crowd_list_id, person_id, added_at, added_by_user_id | Nieuw pivot (oplossing probleem 4). Koppelt person aan crowd_list. UNIQUE(crowd_list_id, person_id). INDEX: (person_id). | +| **registration_form_fields** | id (ULID), event_id, label, slug (string 100), field_type (text\|textarea\|select\|multiselect\|checkbox\|radio\|boolean\|number\|tag_picker), options (JSON nullable), tag_category (string 50 nullable), is_required (bool), is_portal_visible (bool), is_admin_only (bool), is_filterable (bool), section (string 100 nullable), help_text (text nullable), sort_order (int), created_at, updated_at | v1.9 EAV-systeem voor dynamische registratievelden per event. tag_picker: toont organisatie-tags als selecteerbare opties. options JSON OK: opaque config. UNIQUE(event_id, slug). INDEX: (event_id, sort_order), (event_id, is_portal_visible, sort_order). Geen soft delete. | +| **person_field_values** | id (int AI), person_id, registration_form_field_id, value (text nullable), selected_options (JSON nullable) | v1.9 Antwoorden op registratievelden. value voor enkelvoudige velden, selected_options voor multiselect/checkbox/tag_picker. UNIQUE(person_id, registration_form_field_id). INDEX: (registration_form_field_id, value(191)). Geen soft delete. | +| **person_section_preferences** | id (int AI), person_id, festival_section_id, priority (tinyint 1-5) | v1.9 Sectievoorkeuren van vrijwilligers. Zachte hints, geen beloftes. UNIQUE(person_id, festival_section_id). INDEX: (festival_section_id, priority), (person_id). Geen soft delete. | ### 3.5.5a Person Tags & Vaardigheden (v1.8) @@ -387,7 +390,15 @@ Tag-gebaseerd vaardigheden-/competentiesysteem voor vrijwilligers en crew. Tags PersonResource wordt verrijkt met tags wanneer `user_id` is ingevuld. Filter endpoints: `?tag={id}` (enkelvoudig) en `?tags=id1,id2` (AND-logica: moet alle tags hebben). -### 3.5.5b Identiteitsmatching: Person ↔ User (v1.8 — ontwerp) +### 3.5.5b Registratievelden & Sectievoorkeuren (v1.9) + +EAV-systeem voor dynamische event-specifieke registratievelden, ter vervanging van queryable gebruik van `persons.custom_fields` JSON. Organisatoren configureren per event welke extra velden verschijnen in het registratieformulier. Antwoorden worden queryable opgeslagen in `person_field_values`. Sectievoorkeuren worden apart vastgelegd in `person_section_preferences`. + +Speciaal veldtype `tag_picker`: toont organisatie-tags als selecteerbare opties. Na account-aanmaak synchroniseert `TagSyncService` selecties naar `user_organisation_tags` met `source = self_reported`. + +Tabellen: `registration_form_fields`, `person_field_values`, `person_section_preferences` (zie sectie 3.5.5 tabeloverzicht hierboven). Templates: `registration_field_templates` (organisatie-niveau, zelfde patroon als `crowd_types`). + +### 3.5.5c Identiteitsmatching: Person ↔ User (v1.8 — ontwerp) Enterprise-grade identiteitsresolutie met drie stappen: detectie → suggestie → bevestiging. Er vindt nooit stilzwijgend automatische koppeling plaats. @@ -602,17 +613,33 @@ Vrijwilligers zijn de kern van elke festival-organisatie. Dit module ontlast de ### 4.4.1 Publiek registratieformulier (meerdelige structuur): -- **Deel 1 — Over jou: Naam, e-mail, telefoon (met landcode).** +- **Deel 1 — Over jou:** Naam, e-mail, telefoon (met landcode), geboortedatum. + Vaste velden, altijd aanwezig. -- **Deel 2 — Meer over jou: Shirtmaat, EHBO, allergieën, rijbewijs. Geconfigureerd via de formulierbouwer.** +- **Deel 2 — Extra informatie:** Dynamische velden geconfigureerd per event via + Registratievelden beheer (sectie 4.4.7). Voorbeelden: shirtmaat, allergieën, + dieetwensen, vergoedingskeuze, toestemming gegevensverwerking, noodcontact, + rijbewijs, EHBO-ervaring, opmerkingen. Elk veld heeft een configureerbaar type + (tekst, selectie, checkbox, ja/nee, getal, tag-kiezer). Kan verplicht worden + gemaakt en als filterbaar worden gemarkeerd voor gebruik bij shift-toewijzing. -- **Deel 3 — Motivatie: Waarom wil je vrijwilliger zijn? Dropdown + vrije tekst.** + Speciaal veldtype 'Tag-kiezer': toont de organisatie-tags (bijv. vaardigheden, + certificaten) als selecteerbare opties. Selecties worden na account-aanmaak + automatisch gesynchroniseerd naar het tagprofiel van de gebruiker als + self-reported tags. -- **Deel 4 — Voorkeurssecties: Selecteer secties en rangschik prioriteit 1-5 (drag-to-prioritize).** +- **Deel 3 — Voorkeurssecties:** Selecteer secties en rangschik prioriteit 1-5 + (drag-to-prioritize). Begeleidende tekst: 'We proberen hier zoveel mogelijk + rekening mee te houden, maar de uiteindelijke indeling wordt bepaald door de + organisatie.' Per event aan/uit te zetten via event-instellingen + (`registration_show_section_preferences`). -- **Deel 5 — Beschikbaarheid: Selecteer Time Slots (gefilterd op VOLUNTEER type). Toont minimumurendrempel voor festivalpas.** +- **Deel 4 — Beschikbaarheid:** Selecteer Time Slots (gefilterd op VOLUNTEER type). + Per event aan/uit te zetten via event-instellingen + (`registration_show_availability`). -- **Deel 6 — Admin only: Blacklist toggle, betaalstatus, algemene notities.** +- **Deel 5 — Admin only (niet zichtbaar voor deelnemer):** Blacklist toggle, + algemene notities (admin_notes). ### 4.4.2 Vrijwilligersprofiel (platform-breed, suggestie 1): @@ -670,6 +697,62 @@ Elke vrijwilliger heeft één platform-breed profiel. Eenmalig invullen, herbrui - Na afloop: automatisch bedankbericht aan alle vrijwilligers. Gepersonaliseerd: naam, sectie, uren gedraaid, 'Zonder jou was dit festival niet mogelijk.' +### 4.4.7 Registratievelden beheer (event settings) + +Organisatoren configureren per event welke extra velden verschijnen in het +registratieformulier (Deel 2). Dit biedt maximale flexibiliteit zonder +hardcoded velden. + +Beheer via: Event Settings > Registratievelden. + +Per veld instelbaar: +- Label (vraagtekst) +- Type: vrije tekst, tekstvak, enkele keuze (select/radio), meervoudige keuze + (multiselect/checkbox), ja/nee, getal, tag-kiezer +- Opties (bij keuze-velden): lijst van antwoordmogelijkheden +- Tag-categorie (bij tag-kiezer): optioneel filteren op tag-categorie +- Verplicht: ja/nee +- Zichtbaar voor deelnemer: ja/nee (admin-only velden zijn alleen zichtbaar + voor de organisator) +- Filterbaar: ja/nee (beschikbaar als filter bij het inplannen van shifts) +- Sectie: groepering in het formulier (bijv. 'Vergoeding', 'Toestemming') +- Helptekst: toelichting onder het veld + +Voorbeelden van dynamische velden: +- 'Shirtmaat' (select: XS/S/M/L/XL/XXL) — filterbaar +- 'Dieetwensen' (multiselect: Vegetarisch/Veganistisch/Halal/Glutenvrij/ + Lactosevrij/Geen pinda's/Geen noten) — filterbaar +- 'Vergoeding' (radio: Pro Deo / Entreeticket / Vrijwilligersvergoeding) +- 'Toestemming gegevensverwerking' (checkbox: verplicht, met lange helptekst) +- 'Ben je eerder vrijwilliger geweest?' (ja/nee) — filterbaar +- 'Certificaten & vaardigheden' (tag-kiezer, categorie: Certificaat) — tags + worden na account-aanmaak gesynchroniseerd naar het tagprofiel +- 'Noodcontact naam' (tekst) + 'Noodcontact telefoon' (tekst) +- 'Betaald' (ja/nee, admin-only) — alleen zichtbaar voor organisator +- 'Opmerkingen' (tekstvak) + +Antwoorden worden queryable opgeslagen (EAV-structuur, niet als JSON) en zijn +beschikbaar als filter in de personenlijst en bij shift-toewijzing. + +Snel starten met veelgebruikte velden: +- 'Kies uit templates': de organisatie heeft een bibliotheek van herbruikbare + veldtemplates (shirtmaat, dieetwensen, vergoeding, toestemming, etc.). Bij + het aanmaken van een organisatie worden systeemtemplates automatisch + aangemaakt. Organisatie-admins kunnen templates aanpassen en eigen templates + toevoegen via Organisatie Settings > Registratieveld-templates. Bij het + toevoegen van een veld aan een event wordt een kopie gemaakt — de event- + kopie is onafhankelijk van de template. +- 'Importeer velden van vorig event': kopieert alle registratievelden van een + eerder event van dezelfde organisatie naar het huidige event. Bestaande + velden blijven intact. + +Tag-kiezer synchronisatie: wanneer een vrijwilliger tags selecteert via een +tag-kiezer veld, worden deze opgeslagen als registratieveld-antwoorden. Zodra +de vrijwilliger een platform-account krijgt (na goedkeuring of via identity +matching), synchroniseert het systeem de selecties automatisch naar +user_organisation_tags als self-reported tags. Organisator-toegekende tags +worden hierbij nooit overschreven. + ## 4.5 Communicatiehub (suggestie 2) Communicatie is op show-dag een van de grootste operationele risico's. Het platform biedt een centrale hub met drie niveaus van urgentie. De coordinator kiest urgentie — het systeem kiest het juiste kanaal.