docs(forms): SCHEMA crosswalk, foundation concept page, getting-started + migration playbook, copy catalogue init
SCHEMA.md - New §3.5.12 "Form Builder" with the legacy-tables-retained note placed prominently directly under the section header (per S1 wrap-up Path 3 decision: Phase 8 deferred to S2). - Crosswalk: every legacy volunteer_profiles column → its new home (user_profiles columns vs form_fields vs person_tags). - Summary table for the 13 new tables with one-line purpose + ARCH § pointer each. - Activity log strategy and multi-tenancy discipline noted. - §3.5.4 marked SUPERSEDED with a pointer to the new section. /dev-docs/form-builder-migration-playbook.md (new) - Operator runbook for forms:migrate-legacy-data on real legacy data. - Pre-flight audit, dry-run, migrate, verify, spot-check, rollback paths spelled out. Same legacy-tables-retained note prominently. /dev-docs/form-builder-getting-started.md (new) - Developer onboarding. Mental model, code samples for creating a schema/field/submission/value, adding a new subject type, registering a custom field type, suppressing activity log via App\Support\ActivityLog::suppressed. /dev-docs/COPY_CATALOGUE.md (new) - Seeded verbatim from ARCH §30 (naming conventions, tooltip catalogue, warning catalogue) with a header explaining purpose, growth strategy, and the per-PR update workflow. /docs/organizer/forms/concepts/wat-is-een-formulier.md (new VitePress) - Dutch, informal je/jij. Follows /docs/.templates/concept-page.md. - Three example use-cases: vrijwilligersregistratie, artist advance, incidentrapportage. Light foundation; depth arrives in S2-S5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
120
dev-docs/COPY_CATALOGUE.md
Normal file
120
dev-docs/COPY_CATALOGUE.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Crewli — In-app copy catalogue
|
||||
|
||||
> Single source of truth for user-facing Dutch copy. Seeded from
|
||||
> `/dev-docs/ARCH-FORM-BUILDER.md` §30.
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises terminology and warnings so every screen, tooltip, and
|
||||
error message stays consistent. Without this, "Dienst" / "Shift" /
|
||||
"Taak" drift across pages and confuse organisers; "Privacy-gevoelig"
|
||||
and "Bevat persoonsgegevens" appear side-by-side for the same flag.
|
||||
|
||||
This catalogue is the canonical reference for both backend validation
|
||||
messages (Form Requests) and frontend rendering (Vue components).
|
||||
Whenever you write Dutch copy that a user will read, look here first.
|
||||
|
||||
## Growth strategy
|
||||
|
||||
Living document. Each session that adds user-facing UI:
|
||||
|
||||
1. **Check first.** Search for the concept (in Dutch and English) before
|
||||
coining a new term. If it's here, use the existing wording verbatim.
|
||||
2. **Propose new terms in PR.** Add to the relevant section. Brief
|
||||
rationale in the commit message.
|
||||
3. **Reviewer (Bert) signs off** on terminology before merge. Once a
|
||||
term is in the catalogue, it's binding for future sessions.
|
||||
|
||||
The catalogue stays in `/dev-docs/` (developer-facing) rather than
|
||||
`/docs/` (end-user-facing). End-user docs are written *using* this
|
||||
catalogue, not *about* it.
|
||||
|
||||
## Update workflow
|
||||
|
||||
- Adding a tooltip → update §30.2 here.
|
||||
- Adding a warning → update §30.3 here.
|
||||
- Renaming a concept → update §30.1 here AND grep for all current
|
||||
Dutch occurrences (frontend, validation messages, end-user docs)
|
||||
and update them in the same PR.
|
||||
- Wholesale restructure → discuss in design-document.md first; then
|
||||
rewrite this catalogue in one commit.
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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."
|
||||
```
|
||||
|
||||
## 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."
|
||||
```
|
||||
@@ -669,6 +669,13 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
## 3.5.4 Volunteer Profile & History
|
||||
|
||||
> **SUPERSEDED by S1 of the form-builder refactor.** The old
|
||||
> `volunteer_profiles` table was a planning placeholder that was never
|
||||
> physically created. It has been split: user-universal columns moved
|
||||
> to the new `user_profiles` table (§3.5.12), and event-variable / skill
|
||||
> columns moved to `form_fields` and `person_tags`. See §3.5.12 for the
|
||||
> new structure and the column-by-column crosswalk.
|
||||
|
||||
### `volunteer_profiles`
|
||||
|
||||
| Column | Type | Notes |
|
||||
@@ -1924,3 +1931,59 @@ Immutable audit record of every email sent. No soft deletes.
|
||||
**Indexes:** `(organisation_id, created_at)`, `(recipient_email, created_at)`, `(template_type, status)`, `(event_id)`, `(person_id)`
|
||||
**Relations:** `belongsTo` organisation (nullable), event (nullable), person (nullable), user (nullable), triggeredBy → user
|
||||
**Soft delete:** no — immutable audit table
|
||||
|
||||
---
|
||||
|
||||
## 3.5.12 Form Builder
|
||||
|
||||
> See `/dev-docs/ARCH-FORM-BUILDER.md` v1.2 for the authoritative
|
||||
> 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`.
|
||||
|
||||
**Crosswalk: legacy `volunteer_profiles` → new locations**
|
||||
|
||||
| Legacy column | New location |
|
||||
| ------------------------------ | ----------------------------------------------- |
|
||||
| `bio` | `user_profiles.bio` |
|
||||
| `photo_url` | `user_profiles.photo_url` |
|
||||
| `tshirt_size` | `form_fields` (Pattern B, per event) |
|
||||
| `first_aid` | `person_tags` (system-seeded, S3) |
|
||||
| `driving_licence` | `person_tags` (system-seeded, S3) |
|
||||
| `allergies` | `form_fields` (Pattern B, per event) |
|
||||
| `access_requirements` | `form_fields` (Pattern B, per event) |
|
||||
| `emergency_contact_name` | `user_profiles.emergency_contact_name` |
|
||||
| `emergency_contact_phone` | `user_profiles.emergency_contact_phone` |
|
||||
| `reliability_score` | `user_profiles.reliability_score` |
|
||||
| `is_ambassador` | `user_profiles.is_ambassador` |
|
||||
|
||||
### New tables (S1)
|
||||
|
||||
| Table | Purpose |
|
||||
| --- | --- |
|
||||
| `user_profiles` | User-universal profile (bio, photo, emergency contact, reliability_score, is_ambassador, opaque settings JSON). 1:1 with `users`, auto-created by `UserObserver`. ARCH §4.13. |
|
||||
| `form_schemas` | Form definition. Polymorphic owner (event / user_profile / artist / company / null). Carries purpose, submission_mode, is_published, public_token, schema_snapshot policy, freeze_on_submit, retention/consent. ARCH §4.1. |
|
||||
| `form_schema_sections` | Optional sections within a schema (used when `section_level_submit=true`). ARCH §4.8. |
|
||||
| `form_field_library` | Reusable cross-schema field definitions. ARCH §4.7. |
|
||||
| `form_fields` | Field within a schema. `field_type` stored as string (custom types via `CustomFieldTypeRegistry`). Carries binding (Pattern A/B/C), is_filterable, is_pii, conditional_logic, value_storage_hint. ARCH §4.2. |
|
||||
| `form_submissions` | One submission per `(schema, subject)`. Polymorphic subject. Carries status, review_status, schema_snapshot copy, submission lifecycle timestamps, search_index. ARCH §4.3. |
|
||||
| `form_submission_section_statuses` | Per-section status when `section_level_submit=true`. ARCH §4.9. |
|
||||
| `form_submission_delegations` | "X fills in this submission on behalf of Y". ARCH §4.10. |
|
||||
| `form_values` | EAV row per `(submission, field)`. Integer AI PK for fast joins. Typed columns (value_indexed/number/date/bool) populated by `FormValueObserver`. ARCH §4.4. |
|
||||
| `form_value_options` | Filter pivot for multi-value fields (MULTISELECT/CHECKBOX_LIST/TAG_PICKER). Rebuilt by observer. ARCH §4.5. |
|
||||
| `form_templates` | Org-scoped reusable schema snapshots. `is_system` for shipped templates. ARCH §4.6. |
|
||||
| `form_schema_webhooks` | Webhook subscriptions per schema. URL/secret encrypted via Eloquent cast. ARCH §4.11. |
|
||||
| `form_webhook_deliveries` | Webhook delivery audit + retry queue. ARCH §4.12. |
|
||||
|
||||
**Activity log strategy:** explicit calls via `FormSchema::logSchemaChange()` and `FormField::logFieldChange()` — no `LogsActivity` trait (would produce noise). Only impactful events logged (publish toggle, purpose change, binding change, is_pii toggle, etc.). Bulk-fixture suppression via `App\Support\ActivityLog::suppressed(fn() => …)` which flips `config('activitylog.enabled')` for the callback.
|
||||
|
||||
**Multi-tenancy:** `OrganisationScope` applied on `FormSchema`, `FormTemplate`, `FormFieldLibrary`. Other form-builder tables inherit isolation through their parent schema; `FormSchemaWebhook` documents this discipline explicitly via a docblock warning to never query directly without an eager constraint.
|
||||
|
||||
230
dev-docs/form-builder-getting-started.md
Normal file
230
dev-docs/form-builder-getting-started.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Form Builder — Developer Getting Started
|
||||
|
||||
> Onboarding for developers writing code against the new form-builder
|
||||
> tables. Companion to `/dev-docs/ARCH-FORM-BUILDER.md` v1.2 (the
|
||||
> authoritative spec) and `/dev-docs/SCHEMA.md` §3.5.12.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Laravel 12 / PHP 8.2+.
|
||||
- Familiarity with Eloquent, ULIDs, polymorphic relationships.
|
||||
- Read ARCH §0 (TL;DR) and §3 (FormPurpose catalogue).
|
||||
|
||||
---
|
||||
|
||||
## The mental model in one paragraph
|
||||
|
||||
A `FormSchema` is a form definition. `FormFields` belong to it. A
|
||||
`FormSubmission` is one filled-in instance of a schema, identified by its
|
||||
polymorphic subject (a Person, User, Company, Organisation, Event, or
|
||||
nothing for public forms). `FormValues` are the EAV row per
|
||||
`(submission, field)` pair, JSON payload + typed columns populated by an
|
||||
observer based on the field's storage hint and filterability. Every
|
||||
schema declares its purpose (`event_registration`, `incident_report`,
|
||||
…) which constrains lifecycle and integrations.
|
||||
|
||||
---
|
||||
|
||||
## Creating a new schema with two fields
|
||||
|
||||
```php
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Enums\FormBuilder\FormSubmissionMode;
|
||||
use App\Models\Event;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
|
||||
$event = Event::find($eventId);
|
||||
|
||||
$schema = FormSchema::create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'owner_type' => 'event',
|
||||
'owner_id' => $event->id,
|
||||
'name' => "{$event->name} — registratie",
|
||||
'slug' => "{$event->slug}-registratie",
|
||||
'purpose' => FormPurpose::EVENT_REGISTRATION,
|
||||
'submission_mode' => FormSubmissionMode::DRAFT_SINGLE,
|
||||
'is_published' => false,
|
||||
'locale' => 'nl',
|
||||
]);
|
||||
|
||||
FormField::create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::SELECT->value,
|
||||
'slug' => 'shirtmaat',
|
||||
'label' => 'Shirtmaat',
|
||||
'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
|
||||
'is_required' => true,
|
||||
'is_filterable' => true,
|
||||
'value_storage_hint' => FormFieldType::SELECT->recommendedValueStorageHint(),
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
FormField::create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::TEXTAREA->value,
|
||||
'slug' => 'opmerkingen',
|
||||
'label' => 'Opmerkingen',
|
||||
'value_storage_hint' => FormFieldType::TEXTAREA->recommendedValueStorageHint(),
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
```
|
||||
|
||||
Then to log a meaningful change:
|
||||
|
||||
```php
|
||||
$schema->is_published = true;
|
||||
$schema->save();
|
||||
$schema->logSchemaChange('schema.published');
|
||||
```
|
||||
|
||||
Trivial label/help_text edits should NOT call `logSchemaChange` — see
|
||||
ARCH §17.1 for the curated event list.
|
||||
|
||||
---
|
||||
|
||||
## Creating a submission and its values
|
||||
|
||||
```php
|
||||
use App\Enums\FormBuilder\FormSubmissionStatus;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
|
||||
$person = \App\Models\Person::find($personId);
|
||||
|
||||
$submission = FormSubmission::create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'subject_type' => 'person',
|
||||
'subject_id' => $person->id,
|
||||
'submitted_by_user_id' => $person->user_id,
|
||||
'status' => FormSubmissionStatus::SUBMITTED,
|
||||
'submitted_at' => now(),
|
||||
'is_test' => false,
|
||||
]);
|
||||
|
||||
// One value per field. The shape of `value` is JSON-wrapped per field type.
|
||||
FormValue::create([
|
||||
'form_submission_id' => $submission->id,
|
||||
'form_field_id' => $shirtmaatField->id,
|
||||
'value' => ['value' => 'L'],
|
||||
]);
|
||||
|
||||
FormValue::create([
|
||||
'form_submission_id' => $submission->id,
|
||||
'form_field_id' => $opmerkingenField->id,
|
||||
'value' => ['value' => 'Graag een nachtshift'],
|
||||
]);
|
||||
```
|
||||
|
||||
`FormValueObserver` automatically populates `value_indexed` for filterable
|
||||
single-value fields, `value_number` for NUMBER hint, `value_date` for
|
||||
DATE hint, `value_bool` for BOOL hint, and the `form_value_options` pivot
|
||||
for multi-value filterable fields (MULTISELECT / CHECKBOX_LIST /
|
||||
TAG_PICKER). You don't write to those columns directly.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new subject type
|
||||
|
||||
Three places to update — keep them in sync:
|
||||
|
||||
1. **Morph map** (`app/Providers/AppServiceProvider.php`):
|
||||
```php
|
||||
Relation::enforceMorphMap([
|
||||
// …existing entries…
|
||||
'artist' => \App\Models\Artist::class,
|
||||
]);
|
||||
```
|
||||
|
||||
2. **Subject registry** (`config/form_subjects.php`):
|
||||
```php
|
||||
'artist' => [
|
||||
'model' => \App\Models\Artist::class,
|
||||
'display_attribute' => 'name',
|
||||
'permission_check' => \App\Policies\ArtistPolicy::class.'@view',
|
||||
],
|
||||
```
|
||||
|
||||
3. **Reverse relation on the model** itself:
|
||||
```php
|
||||
public function formSubmissions(): MorphMany
|
||||
{
|
||||
return $this->morphMany(\App\Models\FormBuilder\FormSubmission::class, 'subject');
|
||||
}
|
||||
```
|
||||
|
||||
The verifier (`forms:verify-data-integrity`) cross-checks subject_type
|
||||
against the `config/form_subjects.php` keys; an unknown subject_type
|
||||
will fail the submission-coherence check.
|
||||
|
||||
---
|
||||
|
||||
## Registering a custom field type
|
||||
|
||||
The `field_type` column on `form_fields` is a string, not a DB enum, so
|
||||
custom types can be added at runtime via configuration. Under the hood
|
||||
this is the `CustomFieldTypeRegistry` planned for ARCH §17.2 — for now,
|
||||
register the type's value in `config/form_builder.php`:
|
||||
|
||||
```php
|
||||
'custom_field_types' => [
|
||||
'COLOR_PICKER' => [
|
||||
'label' => 'Kleurkeuze',
|
||||
'storage_hint' => 'string',
|
||||
'filterable' => true,
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
The verifier accepts any field_type that's either in `FormFieldType::values()`
|
||||
or in `config('form_builder.custom_field_types')`. Frontend rendering
|
||||
and validation handlers come in S6 when the registry interface lands.
|
||||
|
||||
---
|
||||
|
||||
## Suppressing activity log during bulk operations
|
||||
|
||||
`logSchemaChange` and `logFieldChange` produce one activity-log row per
|
||||
call. In bulk fixture runs (DevSeeder, the data-migration command,
|
||||
import scripts) this floods `activity_log` with hundreds of rows that
|
||||
provide no audit value. Wrap the bulk operation:
|
||||
|
||||
```php
|
||||
use App\Support\ActivityLog;
|
||||
|
||||
ActivityLog::suppressed(function () use ($schemas): void {
|
||||
foreach ($schemas as $schemaData) {
|
||||
$schema = FormSchema::create($schemaData);
|
||||
// logSchemaChange calls inside this closure are silent no-ops.
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The helper flips `config('activitylog.enabled')` for the duration of
|
||||
the callback and restores it in `finally`. Both our explicit calls AND
|
||||
the spatie `LogsActivity` trait (used on `Organisation` elsewhere)
|
||||
respect the flag via `ActivityLogger::log()`.
|
||||
|
||||
Do NOT use this in regular request paths — the activity log is the
|
||||
audit trail; suppressing it silently is an antipattern outside of
|
||||
fixtures and one-shot commands.
|
||||
|
||||
---
|
||||
|
||||
## Where to look next
|
||||
|
||||
- **ARCH-FORM-BUILDER.md §4** — full column specs for every table.
|
||||
- **ARCH-FORM-BUILDER.md §6** — binding patterns (entity-owned /
|
||||
form-owned / mirrored).
|
||||
- **ARCH-FORM-BUILDER.md §7** — filter architecture and the
|
||||
`FilterQueryBuilder` interface (S4).
|
||||
- **`config/form_binding.php`** — Entity Column Registry (Pattern A/C
|
||||
binding targets).
|
||||
- **`config/form_subjects.php`** — Subject Type Registry.
|
||||
- **`config/form_builder.php`** — limits, webhook policy, captcha,
|
||||
retention, feature flags.
|
||||
- **`forms:migrate-legacy-data`** + **`forms:verify-data-integrity`** —
|
||||
see the migration playbook in this directory for the operator's view.
|
||||
186
dev-docs/form-builder-migration-playbook.md
Normal file
186
dev-docs/form-builder-migration-playbook.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Form Builder — Migration Playbook
|
||||
|
||||
> Operator runbook for migrating legacy `registration_form_fields` /
|
||||
> `person_field_values` / `registration_field_templates` data into the new
|
||||
> `form_*` structure. Companion to `/dev-docs/ARCH-FORM-BUILDER.md` §11.
|
||||
|
||||
---
|
||||
|
||||
## Status (post-S1)
|
||||
|
||||
**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`.
|
||||
|
||||
Until S2 lands, the legacy registration UI remains functional for any
|
||||
environment that depends on it.
|
||||
|
||||
---
|
||||
|
||||
## When to use this playbook
|
||||
|
||||
Run this when promoting an environment that holds real legacy
|
||||
registration data into the new form-builder schema. For development
|
||||
machines starting from `migrate:fresh --seed`, no migration is needed —
|
||||
DevSeeder produces new-structure data directly.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight audit
|
||||
|
||||
Before touching any data, confirm the working state matches expectations.
|
||||
|
||||
```bash
|
||||
# 1. Working tree clean?
|
||||
git status
|
||||
|
||||
# 2. On the expected branch?
|
||||
git log -1 --oneline
|
||||
|
||||
# 3. Database backup exists? (production / staging only — your call for dev)
|
||||
|
||||
# 4. Verify the new tables are present and the legacy tables still hold data
|
||||
php artisan db:show | grep -E "form_|registration_form_fields|person_field_values|registration_field_templates"
|
||||
```
|
||||
|
||||
If the legacy tables are absent, the migration command will detect this
|
||||
and exit cleanly without doing work — there is nothing to migrate.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Dry run
|
||||
|
||||
Always start with a dry run. It writes nothing and prints the same summary
|
||||
table the real run would produce.
|
||||
|
||||
```bash
|
||||
php artisan forms:migrate-legacy-data --dry-run
|
||||
```
|
||||
|
||||
**What to look for:**
|
||||
- The per-organisation log lines list every event that will get a new
|
||||
schema and the number of fields/submissions per event.
|
||||
- The summary table shows projected counts for `form_schemas`,
|
||||
`form_fields`, `form_submissions`, `form_values`, `form_templates`.
|
||||
- Exit code is `0`.
|
||||
|
||||
If anything looks surprising — wildly off counts, organisations missing,
|
||||
templates duplicated — STOP and investigate. Do not proceed to step 2 until
|
||||
the dry-run output is what you expect.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Run the migration
|
||||
|
||||
```bash
|
||||
php artisan forms:migrate-legacy-data
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
- Per organisation, inside a transaction (one bad org rolls back only itself).
|
||||
- Per event in that org: one `form_schemas` row + N `form_fields` rows.
|
||||
- Per person with `person_field_values` for that event: one `form_submissions`
|
||||
row (status = `submitted` if person.status ∈ applied/approved/no_show
|
||||
else `draft`) + one `form_values` row per legacy value.
|
||||
- `FormValueObserver` populates typed columns and the
|
||||
`form_value_options` pivot during the inserts (per ARCH §7.2).
|
||||
- All `registration_field_templates` rows are wrapped in the ARCH §4.6.1
|
||||
`schema_snapshot` shape and inserted as `form_templates`.
|
||||
- The whole run is wrapped in `App\Support\ActivityLog::suppressed(...)`
|
||||
so the activity log isn't flooded by hundreds of `logSchemaChange` /
|
||||
`logFieldChange` entries from the bulk inserts.
|
||||
- After migration completes, verification runs automatically. Final
|
||||
exit code is whatever `forms:verify-data-integrity` returns.
|
||||
|
||||
**Idempotent:** if a `form_schemas` row already exists for `(org, event,
|
||||
event_registration)`, that event is skipped silently. Safe to re-run.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Verify
|
||||
|
||||
Verification runs automatically at the end of step 2. To re-run it
|
||||
standalone (or to verify state without touching data):
|
||||
|
||||
```bash
|
||||
php artisan forms:verify-data-integrity
|
||||
```
|
||||
|
||||
Or, equivalently:
|
||||
|
||||
```bash
|
||||
php artisan forms:migrate-legacy-data --verify-only
|
||||
```
|
||||
|
||||
**Nine checks:**
|
||||
1. Schema coherence (purpose enum, custom_purpose_slug ⇄ purpose=custom, public_token uniqueness)
|
||||
2. Field coherence (FK valid, field_type ∈ FormFieldType ∪ custom registry, is_filterable matches type, slug unique within schema, binding JSON valid against `config/form_binding.php`)
|
||||
3. Submission coherence (subject_type/subject_id consistency, subject_type ∈ `config/form_subjects.php`, status ⇄ submitted_at)
|
||||
4. Value coherence (orphans, unique pair, length, multi-value sanity, value_indexed only set when field.is_filterable)
|
||||
5. User profile coherence (every user has one profile; reliability_score ∈ [0, 5])
|
||||
6. Data migration counts (skipped when legacy tables absent)
|
||||
7. Orphan records (pivot rows, section statuses)
|
||||
8. Section/schema relation consistency
|
||||
9. (`--strict`) Strict reachability: value.field.schema must equal value.submission.schema
|
||||
|
||||
Exit code `0` means all checks passed. Anything else is a real failure
|
||||
worth investigating before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Spot-check a sample
|
||||
|
||||
After verification is green, eyeball a sample submission to confirm the
|
||||
shape is right.
|
||||
|
||||
```bash
|
||||
php artisan tinker
|
||||
>>> use App\Models\FormBuilder\FormSubmission;
|
||||
>>> $sub = FormSubmission::with(['schema', 'values.field'])->latest()->first();
|
||||
>>> $sub->schema->name;
|
||||
>>> $sub->values->map(fn($v) => [$v->field->slug => $v->value]);
|
||||
```
|
||||
|
||||
Compare against the legacy view of the same person's data in
|
||||
`person_field_values` to confirm nothing was silently lost.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
The data-migration command does not have a `down()` step. If a migration
|
||||
went wrong, the recovery path is:
|
||||
|
||||
1. Restore from backup (production / staging).
|
||||
2. For dev: `php artisan migrate:fresh --seed` rebuilds everything from scratch.
|
||||
|
||||
The legacy tables are preserved during the migration — they are not
|
||||
modified. So even after running `forms:migrate-legacy-data`, the legacy
|
||||
data is still there (until S2 drops the tables). This means a production
|
||||
data-migration error can be recovered by truncating the new form_* tables
|
||||
and re-running with the underlying legacy data still intact.
|
||||
|
||||
---
|
||||
|
||||
## Common gotchas
|
||||
|
||||
- **`registration_form_fields` table absent:** the command detects this
|
||||
and exits 0 silently. Expected behaviour for fresh environments.
|
||||
- **PII heuristic miss:** the heuristic in §11.2.1 of the ARCH covers
|
||||
obvious patterns (email, phone, geboort, allergie, …) but is not
|
||||
exhaustive. After migration, organisers can fine-tune `is_pii` per
|
||||
field via the form-builder UI (S5).
|
||||
- **`is_published` derived from event status:** the command considers an
|
||||
event "published" when its status is one of registration_open / buildup
|
||||
/ showday / teardown / closed. If the legacy registration was open on
|
||||
events with other statuses, adjust `is_published` manually after migration.
|
||||
- **Custom field types:** the command maps the 10 legacy field types
|
||||
(text/textarea/select/multiselect/checkbox/radio/boolean/number/tag_picker/heading)
|
||||
into the new uppercase enum values. If your legacy data has values
|
||||
outside this set, the command will set them to `TEXT` and the verifier
|
||||
will flag them as invalid `field_type`.
|
||||
Reference in New Issue
Block a user