S2a: purge legacy Form Builder PHP code and routes
This commit is contained in:
120
dev-docs/API.md
120
dev-docs/API.md
@@ -605,30 +605,6 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al
|
||||
- `GET /organisations/{org}/events/{event}/persons?tag={person_tag_id}` — filter persons by single tag
|
||||
- `GET /organisations/{org}/events/{event}/persons?tags=ulid1,ulid2` — filter persons by multiple tags (AND logic: must have all)
|
||||
|
||||
## Public Registration Data
|
||||
|
||||
- `GET /public/events/{slug}/registration-data` — public, no auth. Returns event info, available sections, and volunteer time slots for the registration form. Only returns events with status `registration_open`. Only includes sections with `show_in_registration = true` and `type = standard`. For festivals: returns child event sections only (deduplicated by name), excluding parent operational sections. Only includes time slots with `person_type = VOLUNTEER`. Resolves sub-events to parent festival.
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"event": { "id": "01JXYZ...", "name": "Echt Feesten 2026", "start_date": "2026-07-10", "end_date": "2026-07-12", "organisation_id": "01JXYZ..." },
|
||||
"sections": [{ "id": "01JXYZ...", "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-glass", "registration_description": "Tap bier en drankjes voor festivalgangers" }],
|
||||
"time_slots": [{ "id": "01JXYZ...", "name": "Vrijdag Avond", "date": "2026-07-10", "start_time": "18:00:00", "end_time": "02:00:00", "duration_hours": 8 }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
- `404` — Event not found or not accepting registrations
|
||||
|
||||
## Volunteer Registration
|
||||
|
||||
- `POST /events/{event}/volunteer-register` — public, auth-aware (optional Sanctum). Registers a volunteer for an event. Resolves sub-events to the parent festival. Accepts name, email, phone, tshirt_size, motivation, section_preferences, availabilities. Authenticated users have their name/email taken from the auth token. Returns `PersonResource` (201 on new, 200 on re-registration of rejected person).
|
||||
|
||||
## Portal
|
||||
|
||||
- `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.
|
||||
@@ -675,99 +651,27 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al
|
||||
}
|
||||
```
|
||||
|
||||
## Registration Field Templates (Organisation Settings)
|
||||
## Form Builder
|
||||
|
||||
- `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 /organisations/{org}/events/{event}/registration-fields` — list all fields (ordered by sort_order)
|
||||
- `POST /organisations/{org}/events/{event}/registration-fields` — create field (manually or from template)
|
||||
- `POST /organisations/{org}/events/{event}/registration-fields/from-template` — create field from template
|
||||
- `PUT /organisations/{org}/events/{event}/registration-fields/{field}` — update field
|
||||
- `DELETE /organisations/{org}/events/{event}/registration-fields/{field}` — delete field definition (answers preserved)
|
||||
- `POST /organisations/{org}/events/{event}/registration-fields/reorder` — bulk reorder
|
||||
- `POST /organisations/{org}/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.
|
||||
|
||||
### Response Fields
|
||||
|
||||
Each registration form field response includes:
|
||||
|
||||
- `options` — raw stored format (string array or object array, for backwards compatibility)
|
||||
- `normalized_options` — always `[{label, description}]` format (null when field has no options). Descriptions are null when not set. Use this for rendering.
|
||||
- `display_width` — `"full"` or `"half"`, controls form layout column width. Auto-set based on field type when not explicitly provided.
|
||||
|
||||
### 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 /organisations/{org}/events/{event}/persons/{person}/field-values` — all answers for a person
|
||||
- `PUT /organisations/{org}/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 /organisations/{org}/events/{event}/persons/{person}/section-preferences` — list preferences
|
||||
- `PUT /organisations/{org}/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 }
|
||||
]
|
||||
}
|
||||
```
|
||||
The legacy registration-form-fields / person-field-values /
|
||||
registration-field-templates / person-section-preferences endpoints were
|
||||
purged in S2a. Their replacements (new Form Builder CRUD, public form
|
||||
submission endpoints, tag-sync listener) land in S2b+. This section will
|
||||
be filled in then — see `/dev-docs/ARCH-FORM-BUILDER.md` §4 for the
|
||||
target schema and §31 for integration contracts.
|
||||
|
||||
## Person List Filtering (extended)
|
||||
|
||||
Additional filter parameters on `GET /organisations/{org}/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
|
||||
|
||||
Form-field-value filtering (`?field[slug]=value`) was served by the legacy
|
||||
endpoints that were purged in S2a. It returns in S2b on top of
|
||||
`form_values` + `form_value_options` via the FilterQueryBuilder described
|
||||
in `/dev-docs/ARCH-FORM-BUILDER.md` §7.
|
||||
|
||||
_(Extend this contract per module as endpoints are implemented.)_
|
||||
|
||||
## Platform Admin
|
||||
|
||||
@@ -2955,6 +2955,30 @@ Every contract in §31.1-31.8 has a corresponding integration test in
|
||||
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
|
||||
|
||||
@@ -303,6 +303,15 @@ shifts claimen zonder toegang tot de Organizer app.
|
||||
|
||||
---
|
||||
|
||||
### FORM-02 — TAG_PICKER → user_organisation_tags sync rebuild
|
||||
|
||||
**Aanleiding:** TagSyncService verwijderd in S2a Form Builder legacy purge. Semantiek (TAG_PICKER-antwoorden syncen naar user_organisation_tags bij registratie-goedkeuring) blijft valide.
|
||||
**Wat:** Herbouwen als listener op FormSubmissionSubmitted of FormSubmissionApproved tegen de nieuwe FormValue + TAG_PICKER field_type. Integreren met PersonController::approve() workflow zonder directe service-injection.
|
||||
**Eerdere call-sites (nu verwijderd):** PersonController::approve(), PersonIdentityService::syncRegistrationTags().
|
||||
**Prioriteit:** Hoog — moet landen in S2b of S2c, vóór de frontend in S5 opnieuw op het registratieformulier aansluit.
|
||||
|
||||
---
|
||||
|
||||
### SUP-01 — Leveranciersportal + productieverzoeken
|
||||
|
||||
**Aanleiding:** Leveranciers moeten productie-informatie kunnen indienen.
|
||||
|
||||
@@ -800,7 +800,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
| `is_blacklisted` | bool | |
|
||||
| `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. |
|
||||
| `custom_fields` | JSON | Backward compat + truly opaque event-specific data. For queryable registration data, use the Form Builder (`form_values` via `form_fields` — see §3.5.12). |
|
||||
| `deleted_at` | timestamp nullable | Soft delete |
|
||||
|
||||
**Unique constraint:** `UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL`
|
||||
@@ -1568,85 +1568,14 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
---
|
||||
|
||||
## 3.5.5b Registration Form Fields & Section Preferences
|
||||
## 3.5.5b Section Preferences (form-builder integration)
|
||||
|
||||
### `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.)
|
||||
> The legacy `registration_form_fields`, `person_field_values`, and
|
||||
> `registration_field_templates` tables were dropped in S2a. Their
|
||||
> replacement is the Form Builder schema described in §3.5.12.
|
||||
>
|
||||
> 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 OR option objects `{label, description?}`. NULL for tag_picker (options come from person_tags). JSON OK: opaque config. Both formats accepted; `normalized_options` accessor always returns objects. |
|
||||
| `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 |
|
||||
| `display_width` | string(10) | `full` (default) or `half` — controls form layout width |
|
||||
| `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` is retained — it remains the integration
|
||||
> target for the Form Builder's SECTION_PRIORITY field type (ARCH §31.3).
|
||||
|
||||
### `person_section_preferences`
|
||||
|
||||
@@ -1674,78 +1603,6 @@ Design notes:
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
@@ -1788,10 +1645,7 @@ Design notes:
|
||||
| `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)` |
|
||||
|
||||
---
|
||||
|
||||
@@ -1940,15 +1794,14 @@ Immutable audit record of every email sent. No soft deletes.
|
||||
> specification. This SCHEMA.md section is a summary only and will be
|
||||
> fully rewritten at the end of S6.
|
||||
>
|
||||
> **Legacy tables retained intentionally.** The tables
|
||||
> `registration_form_fields`, `person_field_values`, and
|
||||
> `registration_field_templates` remain in the schema through end of S1.
|
||||
> They are dropped atomically in the first S2 commit together with the
|
||||
> removal of legacy controllers, services, requests, resources, policies,
|
||||
> and routes. DevSeeder and FormBuilderDevSeeder no longer write to them;
|
||||
> they hold zero rows in dev but the schema is preserved for environments
|
||||
> with real legacy data that will be migrated via
|
||||
> `forms:migrate-legacy-data`.
|
||||
> **Legacy tables dropped (S2a).** The tables `registration_form_fields`,
|
||||
> `person_field_values`, and `registration_field_templates` were dropped
|
||||
> by migration `2026_04_20_100000_drop_remaining_legacy_registration_tables`
|
||||
> together with the removal of legacy controllers, services, requests,
|
||||
> resources, policies, routes, and models. The migration's `down()` is a
|
||||
> one-way failure — restoration requires `migrate:fresh` or a pre-S2a
|
||||
> backup. Environments with real legacy data must run
|
||||
> `forms:migrate-legacy-data` BEFORE applying the S2a migration.
|
||||
|
||||
**Crosswalk: legacy `volunteer_profiles` → new locations**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user