Five sequential plans: foundations (deferred_bindings, attach.Store, lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation child CRUD with deferral, admin SPA, and unit and security tests. Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
17 KiB
Phase 12.2: Admin form fields: date, file upload, relation editing with deferred binding - Context
Gathered: 2026-10-02 Status: Ready for planning
## Phase BoundaryPlugin admin forms gain three things a downstream project on SummerCMS v0.1 needed:
- A
type: datepickerfield (date, datetime, time). - A
type: fileuploadfield forattachOne/attachMany. - Full child CRUD in the relation manager: create, update and delete of
hasManyandbelongsToManyrelated records in a modal, alongside the existing link and unlink.
All three use Winter-like deferred binding, so they also work on a record that has not been saved yet. Work happens in summercms.go only: modules/cabana, modules/lagoon and modules/lagoon/attach, and the admin SPA (admin/). The module READMEs and the docs/ pages are updated in the same change. The phase ships as tag v0.1.1. It is urgent and independent of Phases 12.1 and 13.
Out of scope: image cropping, nested relation managers inside a modal, Winter field types other than these three, and changes to fonoteka.go's hand-rolled upload code.
## Implementation DecisionsDeferred binding
- D-01: Pending bindings live in Winter's
deferred_bindingstable, with the same columns as Winter's two migrations:id,master_type,master_field,slave_type,slave_id,session_key,pivot_data,is_bind,created_at,updated_at. One column is added: the owning backend admin id.master_typeandslave_typehold the same morph type string thatsystem_files.attachment_typealready uses. No PHP class names. The migration ships as a framework migration set inlagoon, besideattach.Migrations. — Reversibility: costly — the table is migrated in every host application, and changing its shape later needs a migration plus data rewrite. - D-02: Session keys come from the SPA. It generates a cryptographically random key of at least 128 bits each time a form opens, and sends it with every upload, file removal, child write and the final save. The server stores the authenticated admin's id on each binding. A key used by a different admin is treated as unknown: its bindings are neither read nor committed. The server validates the key's format and length.
- D-03: The Winter split decides when deferral applies:
- File uploads and file removals are always deferred until the parent's Save, on new and saved records alike, as Winter's FileUpload widget does (
add($file, $sessionKey)). Cancel or navigating away discards them. - Relation-manager child changes (create, update, delete, link, unlink, pivot edits) are immediate on a saved parent and deferred only while the parent is unsaved, as Winter's RelationController does.
- File uploads and file removals are always deferred until the parent's Save, on new and saved records alike, as Winter's FileUpload widget does (
- D-04: Commit and discard work like this:
- On the parent's create or update save, every binding for
(session_key, admin)is applied inside the save transaction, afterFormBeforeCreate/FormBeforeUpdateand before commit. Binds attach or link, unbinds detach or unlink or delete. - If the transaction rolls back, the bindings stay in place, so a 422 doesn't lose the uploads.
- A successful save deletes the applied binding rows.
- On the parent's create or update save, every binding for
- D-05: Purging is a daily River periodic job plus
summer deferred:purge [--days=N]. The default age is 5 days (Winter'sDeferredBinding::cleanUp(5)), configurable. Both remove expired bindings and the orphaned slave records they point at. A deferred-created child row or an unattachedsystem_filesrow is deleted. For files, the blob and its thumbnails are deleted after commit through the existing two-phaseattach.DeleteKeyspath.
File upload
- D-06: Models declare attachments through an interface,
AttachRelations() []attach.Relation, where each entry carries at leastName,Many(attachOne vs attachMany) andPublic. This follows theAdminRelationContracts()style. Atype: fileuploadfield must name a declared relation, otherwise boot fails with an error naming the plugin, controller and file. - D-07: A new exported API in
lagoon/attachstores an upload. It writes the blob under the Winter partition key and thesystem_filesrow, enforces MIME and size limits, applies the image-content guard (including webp, P12 D-24), and handlessort_orderand thumbnails.cabanauses it, and app plugins can adopt it later. Existing app code that buildsattach.File{}by hand (fonoteka.go album photos, collection media, user avatar) is not changed in this phase, so the change is non-breaking. - D-08: Accepted
fileuploadkeys:mode(image|file),fileTypes,mimeTypes,maxFilesize,maxFiles,imageWidth,imageHeight,thumbOptions(the thumb mode),useCaption(edit title and description) andprompt. Any other key is a boot error (P9 D-06). No cropping. Limits are enforced on the server, never only in the SPA, and the upload route has aMaxBytesReadercap (P7 D-04 pattern). - D-09: Supported operations: upload (deferred, D-03), image preview and thumbnail, remove (deferred), reorder for
attachMany(sort_order), and title/description editing whenuseCaptionis set. They work on both saved and unsaved records. - D-10: Both public and protected attachments are supported. A relation with
Public: truestoresis_public=trueand uses the existing Winter-shaped public URLs (P12 D-22). A relation withPublic: falsestoresis_public=false, and the SPA gets its download and thumbnail through an authenticated admin API route under thebackendguard. That route is scoped to a parent record the admin may access (or to the admin's own pending bindings).
Relation child CRUD
- D-11: The child modal's form comes from
config_relation.yamlmanage.form(Winter style, e.g.$/vendor/plugin/models/child/fields.yaml), with an optionalview.form. It is compiled at boot by the same typed form-schema pipeline and with the same fail-loud rules. - D-12:
toolbarButtonsfollow Winter:create,update,delete,link,unlink. OnhasMany,deletedeletes the child row through the model so lifecycle hooks and soft delete fire, andunlinksets its foreign key to null. OnbelongsToMany,deletedeletes the related record andunlinkremoves the pivot row (as today). Unknown buttons fail at boot. - D-13:
RelationContract(modules/cabana/relation.go) is extended to describehasMany(relation kind plus the child's foreign key) next to the existing pivot shape. The framework still never guesses table or column names (P9 D-16). - D-14: Winter's
pivot.formis supported forbelongsToMany. Pivot fields are edited in a modal when linking and later through "edit pivot". Pivot input is filled through a whitelist of the pivot form's fields.RelationBeforeLinkstill stamps the server-owned pivot columns, and those columns can't be set from the request. - D-15: Every child endpoint is scoped to its parent. The parent is loaded through
FormExtendQuerywith the controller's permissions, and the child must belong to that parent: by FK forhasMany, by pivot row forbelongsToMany, or by the admin's session-key bindings while the parent is unsaved. Otherwise the endpoint returnsnot_found, so a child of another parent can be neither read nor changed. This is success criterion 3, and it gets security tests. - D-16: Child saves go through the related model's
Fill/Validate(422 envelope, P9 D-10) and its lifecycle hooks. The admin-controller hook set from P9 D-13 gains optional relation hooks for child create, update and delete in the same type-asserted style. Exact names are the planner's choice. - D-17: The child modal is a full form.
datepickerandfileuploadwork inside it with the child form's own session key, which is committed with the child's save. If the parent is unsaved too, the child itself is deferred against the parent's key. Arelation-managerfield inside a child form is a boot error.
Datepicker
-
D-18: For
mode: datetime, the value is stored astimestamptzin UTC, and the SPA shows and edits it in the admin's browser timezone.ignoreTimezone: truekeeps the wall-clock value unchanged, with no conversion.dateandtimemodes never convert. -
D-19: Go types:
datetimemaps totime.Time/*time.Time.datemaps to a frameworklagoon.Date(aDATEcolumn; JSON"2026-10-02").timemaps tolagoon.TimeOfDay(aTIMEcolumn; JSON"14:30:00").
Nullable variants are included. All of them implement
Scanner/Valuerand JSON marshalling. A plugin never defines its own date types (success criterion 1). Boot fails if a datepicker field's mode doesn't match its model field's Go type. -
D-20: Accepted
datepickerkeys:mode,format(display only; Winter/moment tokens are mapped to the SPA formatter),minDate,maxDate,yearRange,firstDay,twelveHourandignoreTimezone. Any other key is a boot error.minDate/maxDateare also validated on the server. -
D-21: The SPA picker is built on Reka UI DatePicker / Calendar primitives (already in the stack, P10 D-07), styled with Direction C tokens, locale-aware, with keyboard and a11y support. Native
<input type=date>is not used.
Planning checkpoint (2026-10-02)
- D-22: A child row created under deferral is marked by a framework envelope in the existing
pivot_datacolumn,{"created":true,"pivot":{...}}. No second added column, so D-01's single added column (the admin id) stands. Purge deletes slaves whose binding carriescreated: trueand keeps rows that were only linked. - D-23: A child modal form (
manage.form/view.form) accepts scalar field types plusdatepickerandfileupload.relation,relation-manager,widgetandpartialare boot errors in this phase. belongsTo pickers inside child forms are deferred. - D-24 [informational]: Five sequential plans: (1) foundations in lagoon/attach/conga/pact, (2) cabana datepicker + fileupload + commit, (3) cabana relation child CRUD + deferral, (4) admin SPA, (5) unit and security tests.
Claude's Discretion
- The names of the new
cabanaroutes (upload, file update/reorder/remove, child CRUD, pivot edit, protected file download), provided they sit under the existing{prefix}/api/v1/{vendor}/{plugin}/{controller}/...scheme, userequireAjaxon writes, carry swag annotations, and update the admin OpenAPI document and generated TS types (P10 D-15). - How the session key travels: a header or a body field.
- The exact shape of
attach.Relation, the attach store API, and thelagoon.Date/lagoon.TimeOfDaymethod sets. - Thumbnail size for previews (derived from
imageWidth/imageHeight, with a sensible default). - Whether list columns gain
type: date/type: timerenderers. Add them only if they are trivial next to the existingtype: datetime. - Configuration key names for the purge age and job schedule.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Run the security-review agent, because the phase touches authorization scoping and file uploads.
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Roadmap and prior decisions
.planning/ROADMAP.md§ "Phase 12.2" — goal and the five success criteria.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md— admin API envelope, typed schema withDisallowUnknownField(D-06), hooks (D-13), relation manager link/unlink (D-15, D-16).planning/phases/10-admin-vue-spa/10-CONTEXT.md— SPA stack, FieldRenderer registry (D-05), relation save shape (D-18), cookie auth and CSRF header (D-19), admin OpenAPI → TS types (D-15).planning/phases/10.1-runtime-admin-extension-point/10.1-CONTEXT.md— widget/partial extension seam the new field types sit next to.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md— attach URL prefix (D-22), webp image guard (D-24)
Winter reference behaviour (in the meta repo, ../examples/golem15-wintercms-starter)
vendor/winter/storm/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.phpand2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php— table shape (D-01)vendor/winter/storm/src/Database/Models/DeferredBinding.php—cleanUp(int $days = 5)purge semantics (D-05)vendor/winter/storm/src/Database/Traits/DeferredBinding.php— commit-on-save semantics (D-04)modules/backend/formwidgets/FileUpload.php— upload/remove/sort/caption behaviour and config keys (D-08, D-09)modules/backend/formwidgets/DatePicker.php— modes, keys, timezone handling (D-18, D-20)modules/backend/behaviors/RelationController.php— toolbar buttons,manage.form,pivot.form, deferral only on unsaved parents (D-03, D-11, D-12, D-14)
Framework code and docs to update
modules/cabana/README.md,modules/lagoon/README.md(coversattach) — must document new API, keys and commands in the same changedocs/backend/forms.md,docs/backend/relation-manager.md,docs/database/attachments.md,docs/database/models.md— affected docs pages;go test ./cmd/summer -run TestDocsTreeandsummer docs:build --checkmust pass
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
modules/lagoon/attach:attach.File(system_files),BlobKey/PartitionDirectory,PublicURL,File.Thumb,StaticHandlerPublic(is_public gate), and the two-phaseDeleteForOwner/DeleteKeys. There is no "store an upload" helper yet. D-07 adds one.modules/cabana/relation.go:RelationContract,RelationSchema,RelationService, link/unlink mutation. Pivot-only today, so it needshasMany(D-13).modules/cabana/schema_types.go:FormField, typed schema structs, and the boot-time compile with fail-loud errors.modules/cabana/model_fields.go: reflection over model fields. It already treatstime.Timeand Scanner/Valuer types as scalars, so the D-19 types slot in.admin/src/components/form/registry.ts+fields/*.vue: FieldRenderer registry. NewDatepickerFieldandFileuploadFieldregister here.admin/src/components/relation/RelationManager.vue,RelationPickerModal.vue: extend them with the create/update/delete modal and the pivot modal.- fonoteka.go's
album_photos_controller.go/collection_media_controller.go/classes/album_files.go: working app-side examples of buildingattach.Filerows, sort order and blob writes. They are reference only and are not changed.
Established Patterns
- Optional capabilities are interfaces that the framework type-asserts, as with
Has*in pact and the admin hooks (P9 D-13). - Every write route uses
requireAjax. Every route is under thebackendguard and enforces the controller'sRequiredPermissions. - Six-segment GET routes go through
nestedGetdispatch because ServeMux pattern conflicts. New nested routes must fit this scheme (modules/cabana/http.go). - Framework migrations are gormigrate sets run before plugin sets (
attach.Migrations,lagoon.BackendAdminMigrations,lagoon.QueueMigrations). - River periodic jobs and
summer_jobsrecords come from Phase 11.
Integration Points
- cabana
create/updatesave transaction: apply deferred bindings there (D-04). lagoon.Migrate: register thedeferred_bindingsmigration set.- The admin OpenAPI document and the generated TS types (P10 D-15), with their drift checks.
- The committed SPA
dist/and its drift check (P10 D-04): rebuild after the SPA changes.
</code_context>
## Specific Ideas- Triggered by a downstream project on SummerCMS v0.1 that marked these three gaps with
TODO: requires SummerCMS change. After the phase lands, tag v0.1.1 and tell the user those TODOs can be resolved. - The user chose Winter-like deferred binding over "save the parent first" when the phase was inserted.
- Behaviour should feel like Winter's FileUpload, DatePicker and RelationController, so a WinterCMS developer recognises the YAML keys.
- Image cropping in fileupload (Winter's crop/resize popup): a future admin-fields phase.
- A relation manager nested inside a child modal.
relation(belongsTo) pickers inside child modal forms (D-23).- Migrating fonoteka.go's hand-rolled upload code onto the new
attachstore helper: optional follow-up in the app repo.
Reviewed Todos (not folded)
2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md: unrelated (benchmarking); only a keyword match.nest-framework-packages-under-modules.md: unrelated repo restructuring.backend-admin-api-tokens.md: admin auth, not form fields.bonfire-duplicate-command-names.md: CLI hygiene, unrelated.
Phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def Context gathered: 2026-10-02