diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 99ab31c1..72e33533 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.8** — Updated April 2026 +> **Version: 2.0** — Updated April 2026 > > **Changelog:** > @@ -26,6 +26,29 @@ > Removed minimum volunteer hours threshold concept. Removed hardcoded > motivation form step. Moved payment status from fixed admin field to > dynamic registration field. +> - v2.0: Universal Form Builder replaces event-scoped registration EAV +> (S1 + S2a + S2b landed). Full architecture: +> `/dev-docs/ARCH-FORM-BUILDER.md` v1.2. +> **Added tables (13):** `user_profiles`, `form_schemas`, +> `form_schema_sections`, `form_field_library`, `form_fields`, +> `form_submissions`, `form_submission_section_statuses`, +> `form_submission_delegations`, `form_values`, `form_value_options`, +> `form_templates`, `form_schema_webhooks`, `form_webhook_deliveries`. +> **Dropped tables (3):** `registration_form_fields`, `person_field_values`, +> `registration_field_templates`. +> **Preserved:** `person_section_preferences` — still the target for the +> Form Builder's `SECTION_PRIORITY` field type (ARCH §31.3). +> **Added column:** `organisations.default_locale` (default `'nl'`) — +> last link in the `FormLocaleResolver` fallback chain (ARCH §16.2). +> **Dropped columns on `events`:** `registration_show_section_preferences`, +> `registration_show_availability` (now expressed as +> `form_fields.is_portal_visible` / `conditional_logic`). +> **Renamed concept:** `volunteer_profiles` (planning placeholder, never +> physically created) is retired; user-universal columns live on the new +> `user_profiles` table and event-variable / skill columns moved to +> `form_fields` + `person_tags`. The `§3.5.9 form_submissions` stub +> (tied to the never-created `public_forms` concept) is retired in favour +> of the Form Builder `form_submissions` table described in §3.5.12. --- @@ -45,17 +68,19 @@ 1. [3.5.1 Foundation](#351-foundation) 2. [3.5.1a Multi-Factor Authentication](#351a-multi-factor-authentication) 3. [3.5.2 Locations](#352-locations) -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.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) +4. [3.5.3 Festival Sections, Time Slots & Shifts](#353-festival-sections-time-slots--shifts) +5. [3.5.4 Volunteer Profile & History](#354-volunteer-profile--history) +6. [3.5.5 Crowd Types, Persons & Crowd Lists](#355-crowd-types-persons--crowd-lists) +7. [3.5.5a Person Tags & Skills](#355a-person-tags--skills) +8. [3.5.5b Section Preferences](#355b-section-preferences) +9. [3.5.5c Person Identity Matching](#355c-person-identity-matching) +10. [3.5.6 Accreditation Engine](#356-accreditation-engine) +11. [3.5.7 Artists & Advancing](#357-artists--advancing) +12. [3.5.8 Communication & Briefings](#358-communication--briefings) +13. [3.5.9 Check-In & Operational](#359-check-in--operational) +14. [3.5.10 Email Infrastructure](#3510-email-infrastructure) +15. [3.5.11 Database Design Rules & Index Strategy](#3511-database-design-rules--index-strategy) +16. [3.5.12 Form Builder](#3512-form-builder) --- @@ -88,15 +113,16 @@ ### `organisations` -| Column | Type | Notes | -| ---------------- | ------------------ | -------------------------------------- | -| `id` | ULID | PK | -| `name` | string | | -| `slug` | string | unique | -| `billing_status` | enum | `trial\|active\|suspended\|cancelled` | -| `settings` | JSON | Display prefs only — no queryable data | -| `created_at` | timestamp | | -| `deleted_at` | timestamp nullable | Soft delete | +| Column | Type | Notes | +| ------------------ | ------------------ | ------------------------------------------------------------------ | +| `id` | ULID | PK | +| `name` | string | | +| `slug` | string | unique | +| `billing_status` | enum | `trial\|active\|suspended\|cancelled` | +| `default_locale` | string(10) | **v2.0** default: `'nl'`. Fallback for `FormLocaleResolver` chain (ARCH §16.2) when `users.locale` and `form_schemas.locale` are absent. | +| `settings` | JSON | Display prefs only — no queryable data | +| `created_at` | timestamp | | +| `deleted_at` | timestamp nullable | Soft delete | **Relations:** `hasMany` events, crowd_types, accreditation_categories **Soft delete:** yes @@ -169,10 +195,13 @@ | `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 | +> **v2.0 — removed:** `registration_show_section_preferences` and +> `registration_show_availability` are gone; the equivalent behaviour now +> lives on `form_fields.is_portal_visible` plus per-field +> `conditional_logic` in the Form Builder (§3.5.12). + **Relations:** - `belongsTo` Organisation @@ -669,33 +698,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 | -| ------------------------- | --------------- | ------------------------------------- | -| `id` | ULID | PK | -| `user_id` | ULID FK unique | → users — 1:1 | -| `bio` | text nullable | | -| `photo_url` | string nullable | | -| `tshirt_size` | string nullable | | -| `first_aid` | bool | | -| `driving_licence` | bool | | -| `allergies` | text nullable | | -| `emergency_contact_name` | string nullable | | -| `emergency_contact_phone` | string nullable | | -| `reliability_score` | decimal(3,2) | 0.00–5.00, computed via scheduled job | -| `is_ambassador` | bool | | - -**Unique constraint:** `UNIQUE(user_id)` - ---- +> **v2.0 — `volunteer_profiles` retired.** The old table was a planning +> placeholder that was never physically created. User-universal columns +> (bio, photo, emergency contact, reliability_score, is_ambassador) live on +> the new `user_profiles` table (§3.5.12). Event-variable / skill-like +> columns moved to `form_fields` (per-event, via the Form Builder) and +> `person_tags`. See §3.5.12 for the new table + the column-by-column +> crosswalk. ### `volunteer_festival_history` @@ -1334,39 +1343,15 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; --- -## 3.5.9 Forms, Check-In & Operational +## 3.5.9 Check-In & Operational -### `public_forms` - -| Column | Type | Notes | -| ----------------------------- | ------------- | -------------------------- | -| `id` | ULID | PK | -| `event_id` | ULID FK | → events | -| `name` | string | | -| `crowd_type_id` | ULID FK | → crowd_types | -| `fields` | JSON | Form config — not filtered | -| `conditional_logic` | JSON | Form config — not filtered | -| `iframe_token` | ULID unique | | -| `confirmation_email_template` | text nullable | | -| `is_active` | bool | | - -**Indexes:** `(event_id, crowd_type_id, is_active)` - ---- - -### `form_submissions` - -| Column | Type | Notes | -| ---------------- | --------- | ----------------- | -| `id` | ULID | PK | -| `public_form_id` | ULID FK | → public_forms | -| `person_id` | ULID FK | → persons | -| `data` | JSON | Free form results | -| `submitted_at` | timestamp | | - -**Indexes:** `(public_form_id, submitted_at)`, `(person_id)` - ---- +> **v2.0 — moved:** the former `public_forms` + stub `form_submissions` +> entries here described a never-created iframe-embed feature (FORM-01 in +> BACKLOG). The Form Builder (§3.5.12) now owns every form concept: +> `form_schemas` with `public_token` handle public-facing forms, +> `form_submissions` stores results. If the iframe-embed requirement +> returns it will layer on top of those tables rather than ship a new +> `public_forms` table. ### `check_ins` @@ -1568,21 +1553,23 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; --- -## 3.5.5b Section Preferences (form-builder integration) +## 3.5.5b Section Preferences -> 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. +> Dynamic registration fields moved to Form Builder — see §3.5.10 +> (renumbered to §3.5.12 in v2.0). The legacy `registration_form_fields`, +> `person_field_values`, and `registration_field_templates` tables were +> dropped in S2a. > > `person_section_preferences` is retained — it remains the integration -> target for the Form Builder's SECTION_PRIORITY field type (ARCH §31.3). +> target for the Form Builder's `SECTION_PRIORITY` field type (ARCH §31.3). ### `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`). +> organiser, NOT promises. The organiser retains full flexibility to +> assign anyone anywhere. Captured by the Form Builder's +> `SECTION_PRIORITY` field type on an `event_registration` schema; the +> listener per ARCH §31.3 upserts rows here on submit. | Column | Type | Notes | | --------------------- | ------- | --------------------------- | @@ -1596,10 +1583,13 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; 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`. +- 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. +- Whether the step renders is driven by the form schema — `form_fields.is_portal_visible` + + `conditional_logic` — not by a column on `events`. --- @@ -1790,20 +1780,21 @@ Immutable audit record of every email sent. No soft deletes. ## 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. +> Universal form builder — one schema/field/submission/value stack serving +> 22 `FormPurpose` variants from `event_registration` to `incident_report` +> to `signature_contract`. See `/dev-docs/ARCH-FORM-BUILDER.md` v1.2 for +> the authoritative behaviour spec; this section documents the physical +> tables as landed through S1 + S2a + S2b. Where ARCH §4 and the +> migrations disagree, the migrations win (document code-as-built). > -> **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. +> **Legacy tables dropped (S2a):** `registration_form_fields`, +> `person_field_values`, `registration_field_templates` removed by +> `2026_04_20_100000_drop_remaining_legacy_registration_tables`. That +> migration's `down()` is a hard failure — restoration requires +> `migrate:fresh` or a pre-S2a backup; environments with real legacy +> data must run `forms:migrate-legacy-data` BEFORE applying the drop. -**Crosswalk: legacy `volunteer_profiles` → new locations** +**Crosswalk: legacy `volunteer_profiles` (never physically created) → new locations** | Legacy column | New location | | ------------------------------ | ----------------------------------------------- | @@ -1819,24 +1810,448 @@ Immutable audit record of every email sent. No soft deletes. | `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. | +### `user_profiles` -**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. +> User-universal profile — replaces the never-created +> `volunteer_profiles` placeholder. Holds bio, photo, emergency contact +> details, reliability score, ambassador flag, and an opaque UI/ +> notification `settings` JSON. 1:1 with `users`; auto-created by +> `UserObserver` on user creation and backfilled for existing users via +> `2026_04_19_100001_populate_user_profiles_from_existing_users`. +> Cascade-deleted with the user — no independent soft delete. ARCH §4.13. + +| Column | Type | Notes | +| ------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------- | +| `id` | ULID | PK, `HasUlids` trait | +| `user_id` | ULID FK | → users, cascade delete, UNIQUE (1:1) | +| `bio` | text nullable | | +| `photo_url` | string nullable | | +| `emergency_contact_name` | string nullable | PII | +| `emergency_contact_phone` | string nullable | PII | +| `reliability_score` | decimal(3,2) | default: `0.00`. System-computed; not mass-assignable | +| `is_ambassador` | bool | default: false. System-awarded; not mass-assignable | +| `settings` | JSON nullable | Whitelisted keys only (`ui.*`, `notifications.*`); enforced by `UserProfileSettingsValidator` (ARCH §4.13.1) | +| `created_at` | timestamp | | +| `updated_at` | timestamp | | + +**Relations:** `belongsTo` User. Exposes a `last_submitted_at` accessor +that aggregates the user's submitted, non-test `form_submissions`. +**Indexes:** `(reliability_score)` +**Unique constraint:** `UNIQUE(user_id)` +**Soft delete:** no — cascades on user delete + +--- + +### `form_schemas` + +> Form definition. Polymorphic owner (`event` / `user_profile` / +> `artist` / `company` / `organisation` / null). Carries purpose, mode, +> publication flag, public-token rotation state, snapshot policy, +> freeze-on-submit lock, retention/consent, and collaborative +> edit-lock columns. `OrganisationScope` applied as a global scope. +> ARCH §4.1. + +| Column | Type | Notes | +| -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations, cascade delete | +| `owner_type` | string(50) nullable | polymorph: `event` / `user_profile` / `artist` / `company` / `organisation` / null | +| `owner_id` | ULID nullable | polymorph target | +| `name` | string | | +| `slug` | string | canonical within organisation | +| `purpose` | string(50) | `FormPurpose` enum value | +| `custom_purpose_slug` | string nullable | required when `purpose = custom` | +| `description` | text nullable | | +| `is_published` | bool | default: false | +| `submission_mode` | string(20) | `FormSubmissionMode` enum value | +| `public_token` | ULID nullable | public-facing form URL token | +| `public_token_previous` | ULID nullable | previous token — accepted during the rotation grace window | +| `public_token_rotated_at` | timestamp nullable | rotation stamp; grace expires 7 days later | +| `submission_deadline` | timestamp nullable | | +| `locale` | string(10) | default: `'nl'`; primary locale (translations in `form_fields.translations`) | +| `settings` | JSON nullable | Opaque UI config | +| `version` | int unsigned | default: 1; bumped on structural edits | +| `snapshot_mode` | string(20) | `never` / `on_submit` / `always` (default `never`) | +| `freeze_on_submit` | bool | default: false. After first submitted submission, schema-structural edits are blocked | +| `retention_days` | int unsigned null | After `submitted_at + retention_days`, PII anonymised via `FormSubmissionAnonymisationService` | +| `consent_version` | string nullable | e.g. `"privacy-v2"` | +| `section_level_submit` | bool | default: false. Enables sections-with-own-submit flow | +| `auto_save_enabled` | bool | default: false | +| `max_submissions` | int unsigned null | Optional cap (public schemas) | +| `created_by_user_id` | ULID FK nullable | → users, null on delete | +| `last_updated_by_user_id` | ULID FK nullable | → users, null on delete | +| `edit_lock_user_id` | ULID FK nullable | → users, null on delete. Pessimistic editor lock (ARCH §14.5) | +| `edit_lock_expires_at` | timestamp nullable | Lock expires here; auto-released by acquire attempts after expiry | +| `created_at`, `updated_at` | timestamps | | +| `deleted_at` | timestamp nullable | Soft delete | + +**Relations:** `belongsTo` organisation, createdBy/lastUpdatedBy/editLockUser (User); `morphsTo` owner; `hasMany` fields, sections, submissions, webhooks +**Indexes:** `(organisation_id, purpose)`, `(owner_type, owner_id)`, `(public_token)`, `(public_token_previous)`, `(custom_purpose_slug)` +**Unique constraint:** `UNIQUE(organisation_id, slug)` +**Global scope:** `OrganisationScope` +**Soft delete:** yes + +--- + +### `form_schema_sections` + +> Optional sections within a schema, active when +> `form_schemas.section_level_submit = true`. Supports dependencies: +> a section is gated until its `depends_on_section_id` chain reaches +> `approved`. Cycle detection applied on save. ARCH §4.8 + §4.8.1. + +| Column | Type | Notes | +| ---------------------------- | ------------------ | ----------------------------------------------------------------------------- | +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas, cascade delete | +| `slug` | string | Snapshot-portable section reference | +| `name` | string | | +| `description` | text nullable | | +| `sort_order` | int unsigned | default: 0 | +| `submit_independent` | bool | default: true | +| `depends_on_section_id` | ULID FK nullable | → form_schema_sections (self), null on delete | +| `required_for_schema_submit` | bool | default: true | +| `created_at`, `updated_at` | timestamps | | +| `deleted_at` | timestamp nullable | Soft delete | + +**Relations:** `belongsTo` schema; `belongsTo` parent section; `hasMany` form_fields +**Indexes:** `(form_schema_id, sort_order)` +**Unique constraint:** `UNIQUE(form_schema_id, slug)` +**Soft delete:** yes + +--- + +### `form_field_library` + +> Cross-schema reusable field definitions scoped per organisation. +> Inserting a library field into a schema creates an independent +> `form_fields` row linked back via `library_field_id` for analytics. +> `OrganisationScope` applied. ARCH §4.7. + +| Column | Type | Notes | +| -------------------------- | --------------- | -------------------------------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations, cascade delete | +| `name` | string | | +| `slug` | string | | +| `field_type` | string(50) | One of `FormFieldType` or a registered custom type | +| `label` | string | | +| `help_text` | text nullable | | +| `options` | JSON nullable | Choice options | +| `validation_rules` | JSON nullable | | +| `default_is_required` | bool | default: false | +| `default_is_filterable` | bool | default: false | +| `default_binding` | JSON nullable | Pattern A / B / C (see ARCH §6) | +| `translations` | JSON nullable | Per-locale overrides | +| `description` | text nullable | Admin-only description of intended use | +| `usage_count` | int unsigned | default: 0; incremented by `FormFieldService::insertFromLibrary` | +| `is_system` | bool | default: false; system-seeded rows flip true | +| `is_active` | bool | default: true | +| `created_at`, `updated_at` | timestamps | | + +**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id` +**Indexes:** `(organisation_id, field_type)`, `(organisation_id, is_active)` +**Unique constraint:** `UNIQUE(organisation_id, slug)` +**Global scope:** `OrganisationScope` +**Soft delete:** no + +--- + +### `form_fields` + +> Field within a schema. `field_type` stored as string (not DB enum) so +> `CustomFieldTypeRegistry` can extend the catalogue at runtime. +> Carries the binding (Pattern A/B/C per ARCH §6), filterability, +> PII flag, conditional_logic rules, per-field role restrictions, +> translations, and the storage hint that guides +> `FormValueObserver`. Uniqueness of `slug` within a schema is +> application-enforced (soft deletes prevent a DB-level partial unique). +> ARCH §4.2. + +| Column | Type | Notes | +| -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------- | +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas, cascade delete | +| `form_schema_section_id` | ULID FK nullable | → form_schema_sections, null on delete | +| `library_field_id` | ULID FK nullable | → form_field_library, null on delete | +| `field_type` | string(50) | `FormFieldType` enum value OR a key from `config('form_builder.custom_field_types')` | +| `slug` | string(100) | Unique per schema (application-enforced) | +| `label` | string | Default-locale label | +| `help_text` | text nullable | | +| `section` | string(100) null | Visual grouping header (independent of `form_schema_section_id`) | +| `options` | JSON nullable | Choice options | +| `validation_rules` | JSON nullable | min/max/regex/allowed_mime_types/storage_disk/callback, etc. | +| `is_required` | bool | default: false | +| `is_filterable` | bool | default: false — populates `form_values.value_indexed` / pivot | +| `is_portal_visible` | bool | default: true | +| `is_admin_only` | bool | default: false — convenience for common role restriction | +| `is_unique` | bool | default: false — uniqueness enforced in `FormValueService` (ARCH §4.2.1) | +| `is_pii` | bool | default: false — drives retention + anonymisation | +| `display_width` | string(10) | default: `full`; `FormFieldDisplayWidth` enum | +| `binding` | JSON nullable | Pattern A/B/C binding to entity column (ARCH §6) | +| `conditional_logic` | JSON nullable | show_when rules; cycle detection on save | +| `role_restrictions` | JSON nullable | Per-field RBAC driving `FieldAccessService` | +| `translations` | JSON nullable | `{ : { label, help_text, options } }` | +| `value_storage_hint` | string(10) | default: `json`. `FormValueStorageHint` enum — guides typed-column population | +| `review_required` | bool | default: false | +| `sort_order` | int unsigned | default: 0 | +| `created_at`, `updated_at` | timestamps | | +| `deleted_at` | timestamp nullable | Soft delete preserves history | + +**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values +**Indexes:** `(form_schema_id, sort_order)`, `(form_schema_id, is_filterable)`, `(library_field_id)`, `(form_schema_id, slug)` +**Soft delete:** yes + +--- + +### `form_submissions` + +> One submission per `(schema, subject)` in `single` / `draft_single` +> modes; unbounded in `multiple` mode. Polymorphic `subject_type` / +> `subject_id` (allowed types per `config/form_subjects.php`). +> Carries the lifecycle timestamps, review status, optional schema +> snapshot (when `form_schemas.snapshot_mode != 'never'`), locale used, +> idempotency key, anonymisation marker, and a `search_index` text +> column fed by FULLTEXT on MySQL and by LIKE fallback elsewhere. +> ARCH §4.3. + +| Column | Type | Notes | +| ------------------------------------- | ------------------ | ---------------------------------------------------------------------------- | +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas, cascade delete | +| `subject_type` | string(50) null | polymorph | +| `subject_id` | ULID nullable | polymorph target | +| `submitted_by_user_id` | ULID FK nullable | → users, null on delete | +| `public_submitter_name` | string nullable | Public submitters | +| `public_submitter_email` | string nullable | Public submitters | +| `public_submitter_ip` | string(45) null | IPv4/v6 | +| `public_submitter_ip_anonymised_at` | timestamp nullable | Set by retention job (ARCH §10.3) | +| `status` | string(20) | `FormSubmissionStatus` enum | +| `review_status` | string(30) null | `FormSubmissionReviewStatus` enum | +| `reviewed_by_user_id` | ULID FK nullable | → users, null on delete | +| `reviewed_at` | timestamp nullable | | +| `review_notes` | text nullable | | +| `submitted_at` | timestamp nullable | | +| `schema_version_at_submit` | int unsigned null | `form_schemas.version` at submit time | +| `schema_snapshot` | JSON nullable | Full snapshot when policy dictates (ARCH §4.6.1 shape) | +| `is_test` | bool | default: false — excluded from reporting & retention | +| `submitted_in_locale` | string(10) null | Locale the submitter used while filling in | +| `opened_at` | timestamp nullable | First GET | +| `first_interacted_at` | timestamp nullable | First field focus | +| `submission_duration_seconds` | int unsigned null | opened_at → submitted_at | +| `auto_save_count` | int unsigned | default: 0 | +| `idempotency_key` | ULID nullable | Duplicate-submit guard | +| `anonymised_at` | timestamp nullable | | +| `search_index` | mediumText null | Concatenated text of text-type values; FULLTEXT-indexed on MySQL when supported | +| `created_at`, `updated_at` | timestamps | | +| `deleted_at` | timestamp nullable | Soft delete | + +**Relations:** `belongsTo` schema, submittedBy / reviewedBy (User); `morphsTo` subject; `hasMany` values, section statuses, delegations +**Indexes:** `(form_schema_id, status)`, `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)`, `(form_schema_id, idempotency_key)`, `FULLTEXT(search_index)` (MySQL/InnoDB — best-effort, skipped gracefully on SQLite) +**Events fired:** `FormSubmissionCreated`, `FormSubmissionDraftUpdated`, `FormSubmissionSubmitted`, `FormSubmissionReviewed`, `FormSubmissionSectionSubmitted`, `FormSubmissionSectionReviewed`, `FormSubmissionAnonymised`, `FormSubmissionArchived`, `FormSubmissionDeleted` +**Soft delete:** yes + +--- + +### `form_submission_section_statuses` + +> Per-section lifecycle state when `form_schemas.section_level_submit = +> true`. Integer AI PK — this is a high-volume pivot. ARCH §4.9. + +| Column | Type | Notes | +| -------------------------- | ------------------ | ------------------------------------------------ | +| `id` | int AI | PK | +| `form_submission_id` | ULID FK | → form_submissions, cascade delete | +| `form_schema_section_id` | ULID FK | → form_schema_sections, cascade delete | +| `status` | string(30) | `draft` / `submitted` / `approved` / `rejected` / `changes_requested` | +| `submitted_at` | timestamp nullable | | +| `reviewed_by_user_id` | ULID FK nullable | → users, null on delete | +| `reviewed_at` | timestamp nullable | | +| `review_notes` | text nullable | | +| `created_at`, `updated_at` | timestamps | | + +**Unique constraint:** `UNIQUE(form_submission_id, form_schema_section_id)` +**Soft delete:** no + +--- + +### `form_submission_delegations` + +> "X fills in this submission on behalf of Y." Subject-self grants the +> delegation; the delegatee can view + edit the draft. ARCH §4.10. + +| Column | Type | Notes | +| -------------------------- | ------------------ | ----------------------------------------------- | +| `id` | ULID | PK | +| `form_submission_id` | ULID FK | → form_submissions, cascade delete | +| `delegated_to_user_id` | ULID FK | → users, cascade delete | +| `delegated_by_user_id` | ULID FK | → users, cascade delete | +| `granted_at` | timestamp | | +| `revoked_at` | timestamp nullable | Null = delegation active | +| `message` | text nullable | Optional context from delegator to delegatee | +| `created_at`, `updated_at` | timestamps | | + +**Indexes:** `(delegated_to_user_id, revoked_at)`, `(form_submission_id)` +**Soft delete:** no + +--- + +### `form_values` + +> EAV row per `(submission, field)`. **Integer AI PK** — this table is +> joined heavily and integer joins beat ULID joins for the hot read +> paths. Canonical payload lives in the `value` JSON column; the typed +> columns are derived. ARCH §4.4. +> +> **Observer behaviour (`FormValueObserver`, ARCH §7.2):** +> - When `form_fields.is_filterable = true` on a scalar field, +> `value_indexed` is populated (truncated to 255 chars) from the +> JSON value. +> - When `form_fields.value_storage_hint = number`, `value_number` is +> populated as decimal(15,4). +> - When `value_storage_hint = date`, `value_date` is populated. +> - When `value_storage_hint = bool`, `value_bool` is populated. +> - When `is_filterable = false`, all typed columns are reset to NULL +> and every matching `form_value_options` row is deleted. +> - On filterable multi-value fields (`MULTISELECT`, `CHECKBOX_LIST`, +> `TAG_PICKER`), the `form_value_options` pivot is rebuilt (delete +> all, insert current) on each save. + +| Column | Type | Notes | +| -------------------------- | -------------------- | ------------------------------------------- | +| `id` | int AI | PK | +| `form_submission_id` | ULID FK | → form_submissions, cascade delete | +| `form_field_id` | ULID FK | → form_fields, cascade delete | +| `value` | JSON | Canonical payload | +| `value_indexed` | string(255) nullable | Populated by observer for filterable scalar fields | +| `value_number` | decimal(15,4) null | Populated when `value_storage_hint = number` | +| `value_date` | date nullable | Populated when `value_storage_hint = date` | +| `value_bool` | bool nullable | Populated when `value_storage_hint = bool` | +| `value_anonymised` | bool | default: false — set by anonymisation | +| `created_at`, `updated_at` | timestamps | | + +**Unique constraint:** `UNIQUE(form_submission_id, form_field_id)` +**Indexes:** `(form_field_id, value_indexed)`, `(form_field_id, value_number)`, `(form_field_id, value_date)` +**Soft delete:** no — history preserved via submission soft delete + +--- + +### `form_value_options` + +> Filter pivot for multi-value field types (`MULTISELECT`, +> `CHECKBOX_LIST`, `TAG_PICKER`). Denormalises `form_field_id` and +> `form_submission_id` for fast filtering joins. Rebuilt by the +> observer on every save — rows have no long-term identity beyond the +> row they belong to. ARCH §4.5. + +| Column | Type | Notes | +| -------------------- | ------------ | ----------------------------------------------------- | +| `id` | int AI | PK | +| `form_value_id` | int FK | → form_values, cascade delete | +| `form_field_id` | ULID FK | → form_fields, cascade delete (denormalised) | +| `form_submission_id` | ULID FK | → form_submissions, cascade delete (denormalised) | +| `option_value` | string(255) | Single selected option | + +**Indexes:** `(form_field_id, option_value)`, `(form_submission_id)`, `(form_value_id)` +**Soft delete:** no + +--- + +### `form_templates` + +> Org-scoped reusable schema snapshots. Applying a template creates a +> new `form_schemas` row with fields copied from the snapshot. System +> templates (`is_system = true`) ship with Crewli and cannot be deleted +> — only deactivated. `OrganisationScope` applied. ARCH §4.6. + +| Column | Type | Notes | +| -------------------------- | -------------- | -------------------------------------------------------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations, cascade delete | +| `name` | string | | +| `slug` | string | | +| `purpose` | string(50) | `FormPurpose` enum value — constrains which schemas this template can seed | +| `description` | text nullable | | +| `schema_snapshot` | JSON | Canonical snapshot shape (ARCH §4.6.1) — schema metadata + sections[] + fields[] block | +| `is_system` | bool | default: false | +| `is_active` | bool | default: true | +| `created_at`, `updated_at` | timestamps | | + +**Indexes:** `(organisation_id, purpose, is_active)` +**Unique constraint:** `UNIQUE(organisation_id, slug)` +**Global scope:** `OrganisationScope` +**Soft delete:** no + +--- + +### `form_schema_webhooks` + +> Webhook subscriptions per schema. `url` and `secret` are stored as +> encrypted TEXT (Eloquent cast on the model). API resources never +> echo these back — only `url_host` + `has_secret`. ARCH §4.11. +> `OrganisationScope` NOT applied directly — enforced through the +> parent schema. + +| Column | Type | Notes | +| -------------------------- | ------------ | ----------------------------------------------------------------------------------------- | +| `id` | ULID | PK | +| `form_schema_id` | ULID FK | → form_schemas, cascade delete | +| `name` | string | | +| `trigger_event` | string(40) | `submission_created` / `submission_submitted` / `submission_reviewed` / `section_submitted` / `section_approved` / `section_rejected` | +| `url` | text | Encrypted via Eloquent cast | +| `secret` | text nullable | Encrypted via Eloquent cast; used for HMAC-SHA256 signing | +| `is_active` | bool | default: true | +| `created_at`, `updated_at` | timestamps | | + +**Indexes:** `(form_schema_id, is_active)` +**Soft delete:** no + +--- + +### `form_webhook_deliveries` + +> Delivery audit + retry queue populated by `FormWebhookDispatcher` and +> driven through by `DeliverFormWebhookJob` on the dedicated `webhooks` +> queue. Retries: `{1m, 5m, 30m, 2h, 8h}`, max 5 attempts, status flips +> to `dead_letter` on exhaustion. SSRF-protected. ARCH §4.12 + §17.5. + +| Column | Type | Notes | +| ------------------------- | ------------------------- | -------------------------------------------------------------- | +| `id` | ULID | PK | +| `form_schema_webhook_id` | ULID FK | → form_schema_webhooks, cascade delete | +| `form_submission_id` | ULID FK | → form_submissions, cascade delete | +| `trigger_event` | string(40) | Mirrors the webhook's trigger | +| `status` | string(20) | `FormWebhookDeliveryStatus` enum: `pending` / `delivered` / `failed` / `dead_letter` | +| `attempts` | int unsigned | default: 0 | +| `last_attempt_at` | timestamp nullable | | +| `response_status` | smallint unsigned nullable | HTTP status code | +| `response_body_excerpt` | text nullable | First ~1000 chars | +| `next_retry_at` | timestamp nullable | | +| `delivered_at` | timestamp nullable | | +| `failed_permanently_at` | timestamp nullable | | +| `payload_snapshot` | JSON | Exactly what was (or will be) sent — for replay/audit | + +**Indexes:** `(status, next_retry_at)`, `(form_schema_webhook_id, status)`, `(form_submission_id)` +**Soft delete:** no — no `created_at` / `updated_at` either; `last_attempt_at` is the effective timestamp + +--- + +**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. + +**Tag sync integration (FORM-02, ARCH §31.10):** the +`SyncTagPickerSelectionsOnSubmit` listener fires on +`FormSubmissionSubmitted` for `event_registration` schemas with a +person subject. It rebuilds `user_organisation_tags` rows where +`source = self_reported` through `FormTagSyncService::rebuildForPerson` +— no-op when `person.user_id IS NULL` (the deferred-sync path runs +via `PersonIdentityService::confirmMatch` once the person is linked). **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.