diff --git a/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md b/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md new file mode 100644 index 0000000..baec5fc --- /dev/null +++ b/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md @@ -0,0 +1,289 @@ +--- +phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def +plan: 03 +subsystem: admin +tags: [cabana, relation-manager, hasMany, belongsToMany, pivot, deferred-binding, fileupload, openapi] + +requires: + - phase: 12.2-01 + provides: deferred_bindings store ops (DeferredBind/Unbind/Bindings/Forget/Slaves, DeferredEnvelope), MorphType, pact Relation{Before,After}{Create,Update,Delete} hooks, PurgeDeferred over Models() + - phase: 12.2-02 + provides: X-Session-Key, fileScope and the seven file handlers, commitDeferred in CRUDService.save, fileupload/datepicker compile +provides: + - RelationContract.Kind / ForeignKey with RelationHasMany and RelationBelongsToMany (empty Kind is belongsToMany) + - manage.form / view.form / top-level form and pivot.form compiled against the related or pivot model; $/ paths inside the plugin + - view toolbarButtons create|update|delete|link|unlink as route capabilities + - child create/show/update/delete and pivot show/update routes, parent-scoped through loadChild (404 on a miss) + - hasMany link/unlink, pivot values on link through the pivot form whitelist + - record id 0 relation routes held against X-Session-Key and committed by the record's create save + - child file routes keyed by X-Child-Session-Key (ChildSessionKeyHeader), committed by the child save + - RelationSchema kind, deferrable, manageForm, viewForm, pivotForm; FormField.deferrable; 17 relation message keys + - boot check that a deferrable relation with create has its related model in some plugin's Models() +affects: [12.2-04 admin SPA (child, pivot and create-screen UI on these routes), 12.2-05 unit and security tests] + +actuals: + tokens: 82200 + tasks: 3 + commits: 3 +plan_head_before: 0b4ef9c311036309cd9fdaf5fdd1bc84f79a1297 +plan_head_after: fe9e8baaf14a21f474db405343230071b6b8bdaa + +tech-stack: + added: [] + patterns: + - "A relation form is compiled as a CompiledController of its own (relationModelController around the related or pivot model), so Fill, Validate, file and date pipelines are reused unchanged" + - "Every child, pivot and child-file query carries the parent predicate (FK, pivot EXISTS, or the session's bound slaves); a miss is recordNotFound" + - "File handlers run on a fileRoute (fields, key header, scope), shared by record and relation child file routes" + - "Link and unlink go through linkRelated/unlinkRelated, used by the routes and by the deferred commit" + +key-files: + created: + - modules/cabana/relation_form.go + - modules/cabana/relation_child.go + - modules/cabana/relation_child_smoke_test.go + - modules/cabana/example_relation_test.go + modified: + - modules/cabana/relation.go + - modules/cabana/deferred.go + - modules/cabana/field_file.go + - modules/cabana/http.go + - modules/cabana/registry.go + - modules/cabana/schema.go + - modules/cabana/form_schema.go + - modules/cabana/schema_types.go + - modules/cabana/messages.go + - modules/cabana/admin_openapi.go + - modules/cabana/security_coverage_test.go + - modules/cabana/openapi_conformance_test.go + - modules/cabana/phase10_csrf_test.go + - modules/cabana/phase10_coverage_test.go + - modules/cabana/datepicker_smoke_test.go + - modules/phrasebook/backend/lang/en/lang.yaml + - modules/phrasebook/backend/lang/pl/lang.yaml + - admin/openapi/admin.json + - admin/src/api/schema.d.ts + - admin/tests/fixtures/widgets.relation-schema.json + - modules/cabana/README.md + - docs/backend/relation-manager.md + +key-decisions: + - "12.2-03: a relation form is compiled into its own CompiledController (relationModelController), so the child save reuses projectOperation, fillAllowed, mergedRules, dateBoundDetails and commitDeferred" + - "12.2-03: a hasMany child's ForeignKey is set from the scoped parent before Fill and Validate, so a model rule on the key passes; a body naming the key is dropped" + - "12.2-03: pivot values are filled through the pivot form's writable fields only (the pivot model needs no Fillable), and only the rules of those fields are checked" + - "12.2-03: unknown pivot keys are a 422 on PUT .../pivot/{child} as on link, not silently dropped" + - "12.2-03: at commit a bind of a child the session created attaches directly (hasMany: key set if still NULL; belongsToMany: pivot row with RelationBeforeLink); a bind of an existing record re-runs the full link eligibility" + - "12.2-03: unlink on an unsaved parent only cancels ids bound in the session; no stray unbind rows are written" + - "12.2-03: child file routes on a saved child answer 403 without update (writes) or without update or a view form (reads); child 0 without create is 404, as a record's id 0" + - "12.2-03: the Models() boot check matches the related model by Go type, or by morph type when a database is published" + - "12.2-03: the pending-created exclusion matches pivot_data LIKE '{\"created\":true%', the envelope's encoded prefix, so a malformed pivot_data row cannot break candidate queries" + +patterns-established: + - "relationParent: a saved record (id) or the unsaved one (DeferredKey + related morph); relationQuery and loadChild branch on it" + - "Route capability: relationAllowed(cr predicate) writes 404 for an unknown relation and 403 for an undeclared capability before any SQL" + +requirements-completed: [SC-3, SC-4] + +coverage: + - id: D1 + description: "A hasMany child created under gadget A carries A's id, lists under A only, and a body naming gadget_id cannot move it" + requirement: SC-3 + verification: + - kind: integration + ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeCreate" + status: pass + human_judgment: false + - id: D2 + description: "Show, update, delete and pivot routes answer 404 for a child of another parent and for a FormExtendQuery-hidden parent; a delete naming a foreign child deletes nothing; hasMany link/unlink/delete" + requirement: SC-3 + verification: + - kind: integration + ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeScope" + status: pass + human_judgment: false + - id: D3 + description: "Link with a pivot note stores it, RelationBeforeLink still stamps its hook column, pivot keys outside the form and multi-id pivot links are 422" + requirement: SC-3 + verification: + - kind: integration + ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokePivot" + status: pass + human_judgment: false + - id: D4 + description: "On id 0 a created part and a member linked with a pivot note are attached by the gadget's create save; an unlinked pending part is deleted; no binding is left" + requirement: SC-4 + verification: + - kind: integration + ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeDeferredCreate" + status: pass + human_judgment: false + - id: D5 + description: "A deferred link the saved gadget excludes answers 422 on the relation-manager field, nothing is created, the binding stays" + requirement: SC-4 + verification: + - kind: integration + ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeDeferredRollback" + status: pass + human_judgment: false + - id: D6 + description: "An image uploaded to child 0 with X-Child-Session-Key is attached to the part its create makes" + requirement: SC-4 + verification: + - kind: integration + ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeChildFile" + status: pass + human_judgment: false + - id: D7 + description: "Boot stops when a deferrable relation with create has no Models() entry for its related model" + requirement: SC-4 + verification: + - kind: unit + ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokePurgeModels" + status: pass + human_judgment: false + - id: D8 + description: "All 13 new routes inventoried, guarded, CSRF-walked, in admin.json and called once by the conformance suite; SPA types, fixture and dist in sync" + verification: + - kind: integration + ref: "modules/cabana#TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10CSRF|TestPhase10Coverage|TestPhase10OpenAPIConformance" + status: pass + - kind: other + ref: "scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && npm --prefix admin test" + status: pass + human_judgment: false + - id: D9 + description: "Existing belongsToMany contracts unchanged: fonoteka.go builds, vets and passes its admin tests with no change" + verification: + - kind: integration + ref: "go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run Admin" + status: pass + human_judgment: false + - id: D10 + description: "Docs and README name only existing identifiers and commands" + verification: + - kind: other + ref: "go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check" + status: pass + human_judgment: false + +duration: 45min +completed: 2026-10-02 +status: complete +--- + +# Phase 12.2 Plan 03: Relation child CRUD, pivot forms and deferral Summary + +**hasMany relation contracts, WinterCMS manage/view/pivot forms and toolbar buttons, parent-scoped child and pivot routes, and relation work on a record being created held against X-Session-Key and committed in its create transaction, with child-form uploads keyed by X-Child-Session-Key** + +## Performance + +- **Duration:** about 45 min +- **Started:** 2026-10-02T16:23:05Z +- **Completed:** 2026-10-02T17:08:20Z +- **Tasks:** 3 +- **Files modified:** 26 (summercms.go only; fonoteka.go untouched) + +## Accomplishments + +- `RelationContract` gains `Kind` (empty is belongsToMany) and `ForeignKey`. A hasMany contract with pivot fields, a non-integer `ForeignKey`, or an unknown kind stops boot. Existing contracts compile and behave unchanged, and the fonoteka.go admin tests pass with no change. +- `manage.form`, `view.form` (falling back to a top-level `form`) and `pivot.form` compile against the related or pivot model, with fail-loud errors that name the plugin, controller and file. `$///...` resolves only inside the calling plugin. `relation`, `relation-manager`, `widget` and `partial` fields, and a field named like the hasMany key, stop boot. `pivot[x]` names compile to `x`, and server-owned pivot columns are refused. +- The view panel's `toolbarButtons` accept `create|update|delete|link|unlink`, and each button is the capability of its routes. create and update need a manage form, and unlink on a non-nullable hasMany stops boot. +- New routes: child create, show, update and delete, pivot show and update, and the seven child file routes. Every child is found with one query that carries the parent predicate, so a foreign child or a hidden parent is 404. A delete is all or nothing. +- hasMany link adopts rows whose key is NULL, and unlink clears the key. Rows that another session created and has not saved yet are never candidates. A link may carry pivot values for one id, filled through the pivot form whitelist before `RelationBeforeLink` stamps its hook columns. +- On record id 0 (with `X-Session-Key`, a deferrable relation, create declared and the field in the create context), create, link, unlink, delete and pivot edits are held in `deferred_bindings`. The record's create save applies them in id order with the file bindings. An ineligible link answers 422 on the relation-manager field and keeps the bindings. +- The relation schema carries `kind`, `deferrable` and the localized forms, and the relation-manager field carries `deferrable`. There are 17 new message keys in en and pl. Boot refuses a deferrable create relation whose related model no plugin lists in `Models()`. + +## Task Commits + +1. **Task 1: hasMany contracts, relation forms and child create** - `48a5b80` (feat) +2. **Task 2: parent-scoped child show, update, delete and pivot routes** - `afb05b6` (feat) +3. **Task 3: deferral on unsaved records and child file routes** - `fe9e8ba` (feat) + +## Files Created/Modified + +- `modules/cabana/relation_form.go`: `compileRelationForms`, `compileRelationForm`, `relationModelController`, pivot field normalisation, `checkFormDates` +- `modules/cabana/relation_child.go`: `relationParent`, `loadParent`, `relationQuery`, `loadChild`, `childFileScope`, the `RelationService` child and pivot methods, and the handlers +- `modules/cabana/relation.go`: contract kinds and validation, schema fields, hasMany queries, `linkRelated`/`unlinkRelated`/`linkDeferred`/`unlinkDeferred`, `fillPivot`, `excludePendingCreated` +- `modules/cabana/deferred.go`: `ChildSessionKeyHeader`, `childSessionKeyFrom`, relation bindings in `commitDeferred`, `applyRelationBinding` +- `modules/cabana/field_file.go`: the `fileRoute` abstraction, the child file handlers and `childFiles` +- `modules/cabana/registry.go`: `checkDeferredModels` +- `modules/cabana/http.go`: 13 routes, `relationsFor` (session key), the boot check call +- `modules/cabana/admin_openapi.go`: 13 doc funcs, `AdminRelationLinkRequest`, and session-key headers on the relation routes +- Tests: inventories, CSRF counts, conformance fixture (`conformPart` hasMany with protected attachMany images, members pivot form with hook stamp and ExcludedRelatedIDs, `Models()`), 7 smoke tests +- Docs: `docs/backend/relation-manager.md` (kinds, forms, buttons, child routes, pivot forms, create screen, child files, messages), `modules/cabana/README.md` + +## Decisions Made + +See `key-decisions` in the frontmatter. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] The SPA's typed relation schema fixture failed vue-tsc** +- **Found during:** Task 1 +- **Issue:** `admin/tests/fixtures/widgets.relation-schema.json` is typed against the generated `RelationSchema`, which now requires `kind`, `deferrable` and the 17 new message keys. +- **Fix:** Added those keys to the fixture. The SPA typecheck, the vitest suite and `check-admin-dist.sh` pass. +- **Commit:** 48a5b80 + +**2. [Rule 3 - Blocking] The datepicker smoke fixture copies the gadget form** +- **Found during:** Task 1 +- **Issue:** `datepickerFields` rebuilds the conform gadget's fields.yaml. With the new `parts` relation in config_relation.yaml, boot failed with "relation parts has no relation-manager field". +- **Fix:** Added the `parts` manager to that fixture. +- **Commit:** 48a5b80 + +**3. [Rule 1 - Bug] Doc snippet placement** +- **Found during:** Task 1 +- **Issue:** `docs/backend/admin-controllers.md` embeds the whole `example_controller_test.go`, so a contract example there would have changed an unrelated page. +- **Fix:** The hasMany example lives in its own `example_relation_test.go`, referenced with `src=`. +- **Commit:** 48a5b80 + +**4. [Fixture shape] The child file relation is attachMany, not attachOne** +- **Found during:** Task 3 +- **Issue:** The plan's fixture names an attachOne `image`. The conformance suite must call the reorder route with 200, and reorder is attachMany only. +- **Fix:** `conformPart` declares a protected attachMany `images` with `useCaption`. All seven child file routes are exercised on a protected relation. +- **Commit:** fe9e8ba + +**5. [Fixture shape] The members manager is visible on create** +- **Found during:** Task 3 +- **Issue:** The deferred link tests need the members manager in the create context, but the fixture had `context: [update]`. +- **Fix:** Dropped that context from the conform gadget form. The behaviour change for existing managers is documented. +- **Commit:** fe9e8ba + +--- + +**Total deviations:** 5 (2 blocking, 1 bug, 2 fixture shape) +**Impact on plan:** None adds scope. The shipped API matches the plan's artifacts list. + +## Issues Encountered + +- The shell's `rm` is interactive, so one cleanup command waited for input. It was rerun as `/usr/bin/rm -f`. No effect on the code. +- `gofmt -l` reports six files outside this plan (compass, party, tide, wristband). They were left untouched. + +## Known Stubs + +None. + +## Threat Flags + +None. Every new endpoint and trust boundary is in the plan's threat register (T-12.2-19 to T-12.2-28). + +## User Setup Required + +None. A host application whose deferrable relation declares `create` must list the related model in a plugin's `Models()`, and boot names the relation when it does not. + +## Next Phase Readiness + +- Plan 04 (SPA) can read `kind`, `deferrable`, `manageForm`, `viewForm` and `pivotForm` from the relation schema and `deferrable` from the relation-manager field. It can use `/records`, `/records/{child}`, `/delete` and `/pivot/{child}`, send `X-Session-Key` with id 0, and send `X-Child-Session-Key` with the child modal's file calls and its create or update. +- Plan 05 should cover: + - another admin's pending children (id 0 with a foreign key is 404), + - unlink and delete of pending belongsToMany links, + - pivot edits on id 0, + - commit of unbinds, + - an attachOne child field, + - the 413 on child bodies, + - concurrency of two saves with one key across files and relations. + +## Self-Check: PASSED + +- Created files exist: relation_form.go, relation_child.go, relation_child_smoke_test.go, example_relation_test.go. +- Commits 48a5b80, afb05b6 and fe9e8ba are on master in summercms.go. fonoteka.go has no changes.