Files
summercms/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
Jakub Zych f4ec94531f docs(12.2): create phase plan
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.
2026-10-02 16:53:42 +02:00

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 Boundary

Plugin admin forms gain three things a downstream project on SummerCMS v0.1 needed:

  1. A type: datepicker field (date, datetime, time).
  2. A type: fileupload field for attachOne / attachMany.
  3. Full child CRUD in the relation manager: create, update and delete of hasMany and belongsToMany related 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 Decisions

Deferred binding

  • D-01: Pending bindings live in Winter's deferred_bindings table, 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_type and slave_type hold the same morph type string that system_files.attachment_type already uses. No PHP class names. The migration ships as a framework migration set in lagoon, beside attach.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.
  • 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, after FormBeforeCreate / FormBeforeUpdate and 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.
  • D-05: Purging is a daily River periodic job plus summer deferred:purge [--days=N]. The default age is 5 days (Winter's DeferredBinding::cleanUp(5)), configurable. Both remove expired bindings and the orphaned slave records they point at. A deferred-created child row or an unattached system_files row is deleted. For files, the blob and its thumbnails are deleted after commit through the existing two-phase attach.DeleteKeys path.

File upload

  • D-06: Models declare attachments through an interface, AttachRelations() []attach.Relation, where each entry carries at least Name, Many (attachOne vs attachMany) and Public. This follows the AdminRelationContracts() style. A type: fileupload field 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/attach stores an upload. It writes the blob under the Winter partition key and the system_files row, enforces MIME and size limits, applies the image-content guard (including webp, P12 D-24), and handles sort_order and thumbnails. cabana uses it, and app plugins can adopt it later. Existing app code that builds attach.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 fileupload keys: mode (image|file), fileTypes, mimeTypes, maxFilesize, maxFiles, imageWidth, imageHeight, thumbOptions (the thumb mode), useCaption (edit title and description) and prompt. 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 a MaxBytesReader cap (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 when useCaption is set. They work on both saved and unsaved records.
  • D-10: Both public and protected attachments are supported. A relation with Public: true stores is_public=true and uses the existing Winter-shaped public URLs (P12 D-22). A relation with Public: false stores is_public=false, and the SPA gets its download and thumbnail through an authenticated admin API route under the backend guard. 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.yaml manage.form (Winter style, e.g. $/vendor/plugin/models/child/fields.yaml), with an optional view.form. It is compiled at boot by the same typed form-schema pipeline and with the same fail-loud rules.
  • D-12: toolbarButtons follow Winter: create, update, delete, link, unlink. On hasMany, delete deletes the child row through the model so lifecycle hooks and soft delete fire, and unlink sets its foreign key to null. On belongsToMany, delete deletes the related record and unlink removes the pivot row (as today). Unknown buttons fail at boot.
  • D-13: RelationContract (modules/cabana/relation.go) is extended to describe hasMany (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.form is supported for belongsToMany. 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. RelationBeforeLink still 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 FormExtendQuery with the controller's permissions, and the child must belong to that parent: by FK for hasMany, by pivot row for belongsToMany, or by the admin's session-key bindings while the parent is unsaved. Otherwise the endpoint returns not_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. datepicker and fileupload work 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. A relation-manager field inside a child form is a boot error.

Datepicker

  • D-18: For mode: datetime, the value is stored as timestamptz in UTC, and the SPA shows and edits it in the admin's browser timezone. ignoreTimezone: true keeps the wall-clock value unchanged, with no conversion. date and time modes never convert.

  • D-19: Go types:

    • datetime maps to time.Time / *time.Time.
    • date maps to a framework lagoon.Date (a DATE column; JSON "2026-10-02").
    • time maps to lagoon.TimeOfDay (a TIME column; JSON "14:30:00").

    Nullable variants are included. All of them implement Scanner / Valuer and 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 datepicker keys: mode, format (display only; Winter/moment tokens are mapped to the SPA formatter), minDate, maxDate, yearRange, firstDay, twelveHour and ignoreTimezone. Any other key is a boot error. minDate / maxDate are 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_data column, {"created":true,"pivot":{...}}. No second added column, so D-01's single added column (the admin id) stands. Purge deletes slaves whose binding carries created: true and keeps rows that were only linked.
  • D-23: A child modal form (manage.form / view.form) accepts scalar field types plus datepicker and fileupload. relation, relation-manager, widget and partial are 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 cabana routes (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, use requireAjax on 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 the lagoon.Date / lagoon.TimeOfDay method sets.
  • Thumbnail size for previews (derived from imageWidth / imageHeight, with a sensible default).
  • Whether list columns gain type: date / type: time renderers. Add them only if they are trivial next to the existing type: 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 with DisallowUnknownField (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.php and 2021_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 (covers attach) — must document new API, keys and commands in the same change
  • docs/backend/forms.md, docs/backend/relation-manager.md, docs/database/attachments.md, docs/database/models.md — affected docs pages; go test ./cmd/summer -run TestDocsTree and summer docs:build --check must 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-phase DeleteForOwner / 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 needs hasMany (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 treats time.Time and Scanner/Valuer types as scalars, so the D-19 types slot in.
  • admin/src/components/form/registry.ts + fields/*.vue: FieldRenderer registry. New DatepickerField and FileuploadField register 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 building attach.File rows, 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 the backend guard and enforces the controller's RequiredPermissions.
  • Six-segment GET routes go through nestedGet dispatch 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_jobs records come from Phase 11.

Integration Points

  • cabana create / update save transaction: apply deferred bindings there (D-04).
  • lagoon.Migrate: register the deferred_bindings migration 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.
## Deferred Ideas
  • 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 attach store 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