refactor(form-field): drop form_fields.options + form_field_library.options

Final WS-5d cleanup. The JSON columns that have been unread since
commit 3 are now physically dropped on both source tables. Their
canonical rich-shape lives in form_field_options, accessed
exclusively through the morphMany relation.

Defensive sweep: any lingering translations.{locale}.options key in
either source table's translations bag is stripped. Commit 2's
backfill should already have done so exhaustively; this is
belt-and-braces.

Rollback re-creates the columns as nullable JSON but leaves them
empty. Pair with commit 2's rollback to restore the pre-WS-5d data
shape on every owner row.

The commit-3 getOptionsAttribute accessor-bridge on FormField +
FormFieldLibrary is removed — Eloquent's getAttribute() resolution
now naturally falls through to the morphMany relation since there's
no underlying column to shadow it. New regression test
FormFieldOptionsAccessTest asserts $field->options resolves to an
Eloquent Collection of FormFieldOption instances and lazy-loads in
exactly 2 queries (1 parent + 1 lazy-load options) on a fresh fetch
without with() preload. Same trio for FormFieldLibrary.

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new drop_form_field_options_json_columns migration on the
rollback stack.

Documentation:
  - SCHEMA.md v2.6: form_field_options table documented; options row
    removed from form_fields and form_field_library; morphMany
    relations updated; cross-references to ARCH-FORM-BUILDER §17.6
    and addendum §Q3 WS-5d Uitvoering added on both source-table
    docblocks.
  - ARCH-FORM-BUILDER.md v1.8: new §17.6 "Field options (relational)"
    mirrors the §17.4 / §17.5 relational-sibling structure with
    sub-sections 17.6.1 rationale, 17.6.2 table + catalogue, 17.6.3
    service / scope / cascade / activity log, 17.6.4 snapshot
    embedding, 17.6.5 external API contract. Existing Webhooks
    section renumbered from §17.6 to §17.7.
  - ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md: "Uitvoering — WS-5d
    (2026-04-27)" section added. Eight paragraphs covering the
    snapshot atomic rewrite, strict-fail backfill dispatch, dual
    activity-log emit, four-sibling base-class extraction warrant,
    commit 0 dead-code precondition, the temporary getOptionsAttribute
    accessor-bridge pattern (with reusability note for future
    JSON→relational refactors), the dev-seeder vergoedingstype RADIO
    normalisation (drift correction explicitly distinguished from the
    parallel apps/app RegistrationFieldTemplate description domain),
    and the WS-5 family completion note.
  - BACKLOG.md: FORM-BUILDER-LIBRARY-AUDIT-LOG entry extended to four
    services (adds library.options_replaced); new
    FORM-BUILDER-MORPH-SCOPE-BASE-CLASS entry added as the WS-5d
    follow-up now that all four concrete morph-scope siblings exist.

Tests: 1193 → 1208 green (+15 across commits 3+4+5; this commit alone:
+2 from the regression test).

This completes the WS-5 family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 03:00:20 +02:00
parent dd7dfe9c0b
commit e7c9482474
12 changed files with 475 additions and 102 deletions

View File

@@ -1,4 +1,4 @@
# ARCH — Universal Form Builder (v1.7)
# ARCH — Universal Form Builder (v1.8)
> **Source of truth** for Crewli's universal Form Builder architecture.
> Any discrepancy with SCHEMA.md is resolved in favour of this document
@@ -10,11 +10,18 @@
> columns dropped); WS-5c landed (relational
> `form_field_conditional_logic_groups` + `form_field_conditional_logic_conditions`;
> pre-WS-5c `conditional_logic` JSON column dropped; no library mirror
> per addendum Q3).
> **Version:** 1.7 (§8 restructured into tree-structure, relational-tables,
> service-boundary, operator-catalogue, cycle-detection, activity-log and
> legacy-migration sub-sections; contract unchanged).
> per addendum Q3); WS-5d landed (relational `form_field_options`;
> pre-WS-5d `options` JSON columns dropped on both `form_fields` and
> `form_field_library`; per-option translations live on the option row
> itself). **WS-5 family complete.**
> **Version:** 1.8 (new §17.6 "Field options (relational)" for the WS-5d
> split; §17.4 / §17.5 sibling-catalogue prose extended to mention the
> fourth concrete morph-scope; existing Webhooks section renumbered
> from §17.6 to §17.7).
> **Previous:**
> 1.7 (§8 restructured into tree-structure, relational-tables,
> service-boundary, operator-catalogue, cycle-detection, activity-log
> and legacy-migration sub-sections; contract unchanged),
> 1.6 (new §17.5 "Field configuration (non-validation)" for
> the `form_field_configs` split; §17.4.4 updated with the
> non-validation-key relocation note),
@@ -2667,13 +2674,138 @@ The portal + organizer SPAs are updated in the same work package
---
### 17.6 Webhooks
### 17.6 Field options (relational)
#### 17.6.1 Schema
#### 17.6.1 Rationale
Pre-WS-5d, `form_fields.options` and `form_field_library.options` were
JSON columns of flat string arrays consumed by RADIO / SELECT /
MULTISELECT / CHECKBOX_LIST. The shape conflated three distinct
concerns: the canonical storage value, the default-locale display
label, and per-locale translations (which lived elsewhere as the
parallel `translations.{locale}.options[]` indexed array). WS-5d
splits the bag into one polymorphic relational table where each row is
a single option carrying value, label, sort_order and an optional
per-locale translations JSON map.
WS-5d follows the WS-5a / WS-5b discipline one-for-one: dedicated
service as single writer, UNION-over-two-owner-chains scope, shared
cascade observer. Fourth and final WS-5 sibling — landing it
materialises the four concrete morph-scope implementations and
unblocks the deliberate follow-up of base-class extraction.
#### 17.6.2 Table + catalogue
Single table `form_field_options` carrying:
- `value` — string ≤255 chars, the canonical storage value used by the
`in:options` validator and embedded in `form_values` rows. UNIQUE per
owner.
- `label` — string ≤255 chars, the default-locale display label.
- `sort_order` — int unsigned, stable ordering within owner.
- `translations` — JSON nullable, `{<locale>: <translated label>}` with
BCP-47 short-form locale keys (`nl`, `en`, `nl_BE`, `en_GB`).
Polymorphic owner: morph aliases `form_field` and `form_field_library`,
reused from WS-5a. UNIQUE index `ffo_owner_value_unique` on
`(owner_type, owner_id, value)` is the seed-bug guard — duplicate
values per field have no semantic meaning and must fail at both the
service layer (`assertSpecsValid`) and the DB level. Sort-order index
`ffo_owner_sort_idx` on `(owner_type, owner_id, sort_order)` for
ordered fetches.
Applies only to field types that consume options:
**RADIO / SELECT / MULTISELECT / CHECKBOX_LIST**. TAG_PICKER's category
filter lives in `form_field_configs` (§17.5);
AVAILABILITY_PICKER and SECTION_PRIORITY source options dynamically
from sibling endpoints. Any other field type carrying non-null options
in pre-WS-5d data is a seed bug and the strict-fail backfill rejects
it.
#### 17.6.3 Service / scope / cascade / activity log
`FormFieldOptionService` is the single writer. Public surface:
- `optionsFor(owner)` — eager, ordered by sort_order
- `replaceOptions(owner, specs)` — transactional: validate spec list,
delete prior rows, insert new rows. Returns the fresh collection.
- `copyOptions(from, to)` — pure row-clone for
`FormFieldService::insertFromLibrary` per the addendum Q3 row-copy
mandate. No activity-log emit (the wrapping field-creation event
carries the audit).
- `toJsonShape(collection)` — serialises to the rich-shape array used
by snapshot writer, API resources and `FilterRegistryController`.
- `assertSpecsValid(specs)` — public spec-shape gate, used by
FormRequests in their `after()` hook to reject malformed specs at the
HTTP boundary before any write.
`FormFieldOptionScope` is the fourth concrete UNION-over-two-owner-
chains sibling, near-duplicate of the binding / validation-rules /
configs scopes. Base-class extraction across the four siblings is
deliberately deferred to a follow-up work package now that the four
implementations exist and the "what actually varies" question can be
answered empirically.
`FormFieldChildTablesCascadeObserver` extended to physically delete
option rows on owner soft-delete OR force-delete; options are
physical state, not audit (submission snapshots carry the historical
shape).
Activity log dual-emit on FormField subject only (mirrors §6.7 /
§17.4.2):
- `field.updated` carries `old.options` / `new.options` diff via
`toJsonShape()` reconstruction. The diff is byte-equal JSON-compared
to skip cosmetic false positives — bare label/sort_order updates
that don't touch options omit the key entirely.
- `field.options_replaced` is the semantic event from
`replaceOptions()`, payload `{options: [...rich shape...]}`.
Library-subject writes are silent in activity log (consistent with
WS-5a / WS-5b convention; library audits live elsewhere).
#### 17.6.4 Snapshot embedding
`FormSubmissionService::buildSnapshot` walks `fields[*]` and emits
options through `FormFieldOptionService::toJsonShape()` in the same
rich shape exposed by API resources. The pre-WS-5d
`translations.{locale}.options[]` parallel arrays are dead — option
translations live on each option row's own `translations` JSON. The
field-snapshot's translations bag retains only `{label, help_text}`
per locale. WS-5d commit 2's backfill rewrote every existing
submission + template snapshot in-place; no historical flat-array
options remain post-commit-2.
#### 17.6.5 External API contract (no bridging)
Resources, snapshot writer, and `FilterRegistryController` emit
options uniformly as the rich shape:
```json
[
{"value": "red", "label": "Red", "sort_order": 0,
"translations": {"nl": "Rood"}},
{"value": "green", "label": "Green", "sort_order": 1}
]
```
Empty option set serialises as `null` (preserves the option-less
field-type contract). Per ARCH-FORM-BUILDER §0 "Breaking change
acceptance", the portal SPA was migrated atomically in WS-5d commit 4
with no flat-array carve-out. Downstream consumers wanting the raw
value list extract `options.map(o => o.value)`; consumers wanting
Vuetify-style `{value, title}` pairs use `resolveOptionLabel(option,
locale)` from `@form-schema/types/formBuilder` and map over.
---
### 17.7 Webhooks
#### 17.7.1 Schema
See §4.11 `form_schema_webhooks` and §4.12 `form_webhook_deliveries`.
#### 17.6.2 Dispatcher
#### 17.7.2 Dispatcher
`FormWebhookDispatcher` listens for FormSubmissionSubmitted / Reviewed /
SectionSubmitted / SectionReviewed events. On trigger:
@@ -2681,7 +2813,7 @@ SectionSubmitted / SectionReviewed events. On trigger:
- For each: creates a form_webhook_delivery row with status=pending
- Queues `DeliverFormWebhookJob` per delivery on dedicated `webhooks` queue
#### 17.6.3 Delivery job
#### 17.7.3 Delivery job
`DeliverFormWebhookJob` on `webhooks` queue:
- Idempotent (Laravel job with unique ID per delivery)
@@ -2700,7 +2832,7 @@ SectionSubmitted / SectionReviewed events. On trigger:
Response body first 1000 chars stored in `response_body_excerpt` for
debugging.
#### 17.6.4 Security
#### 17.7.4 Security
URL validation in `FormWebhookDispatcher`:
- Parse URL; reject non-http(s)
@@ -2712,7 +2844,7 @@ URL validation in `FormWebhookDispatcher`:
Admin UI shows validation status + last delivery attempt per webhook.
#### 17.6.5 Webhook payload format
#### 17.7.5 Webhook payload format
```json
{