Files
summercms/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-PATTERNS.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

253 lines
16 KiB
Markdown

# Phase 12.2: Admin form fields (date, file upload, relation editing with deferred binding) - Pattern Map
**Mapped:** 2026-10-02
**Files analyzed:** 26 (new + modified)
**Analogs found:** 24 / 26
All analog paths are git-tracked sources in `summercms.go` (no mirrors).
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `modules/lagoon/date.go` (new) | model value type | transform (Scanner/Valuer/JSON/Text) | `modules/lagoon/encrypted.go` | role-match |
| `modules/lagoon/deferred.go` (new) | model + store | CRUD | `modules/lagoon/attach/file.go` (File model, DeleteForOwner) | role-match |
| `modules/lagoon/deferred_migrations.go` (new) | migration | batch DDL | `modules/lagoon/attach/migrations.go` | exact |
| `modules/lagoon/migrations.go` (mod) | migration runner | batch | itself, `Migrate` lines 63-108 | exact |
| `modules/lagoon/fill.go` (mod) | utility | transform | itself, `convertValue` lines 112-155, 172-193 | exact |
| `modules/lagoon/validate.go` (mod) | utility | transform | itself, `isEmptyValue` line 264 | exact |
| `modules/lagoon/commands.go` (mod, `deferred:purge`) | CLI command | batch | itself, `RuntimeCommands` `migrate` entry lines 16-30 | exact |
| `modules/lagoon/schedule.go` (new) | config/provider | batch | `modules/conga/scheduler.go` `scheduleEntries` 63-99 + `pact.HasSchedule` | role-match |
| `modules/lagoon/attach/store.go` (new) | service | file-I/O | `modules/lagoon/attach/thumb.go` (bucket writes) + `file.go` DeleteKeys | role-match |
| `modules/lagoon/attach/guard.go` (new) | utility | transform | none in framework (app `classes/image_guard.go`); thumb pixel guard `attach/thumb.go:24-28` | partial |
| `modules/lagoon/attach/relation.go` (new) | contract/interface | n/a | `attach.Owner` in `attach/file.go:17-22` | role-match |
| `modules/cabana/form_schema.go` (mod) | config compiler | transform | itself, `compileWidgetKeys` 458-499 | exact |
| `modules/cabana/field_date.go` (new) | config compiler | transform | `compileWidgetKeys` (form_schema.go) | role-match |
| `modules/cabana/field_file.go` (new) | controller | file-I/O / request-response | `modules/cabana/relation.go` handlers + `Link` 625-692 | role-match |
| `modules/cabana/deferred.go` (new) | service | CRUD (tx) | `RelationService.Link` relation.go 625-692; `CRUDService.save` crud.go 310-386 | role-match |
| `modules/cabana/relation.go` / `relation_child.go` | service + controller | CRUD | `relation.go` Link/contract/`compileRelationButtons` 314-336 | exact |
| `modules/cabana/crud.go` (mod: RecordInput.SessionKey, save commit, scalarFormField) | service | CRUD | itself | exact |
| `modules/cabana/list_schema.go` (mod: `isListRelation`, `date`/`time` column types) | config compiler | transform | `model_fields.go:70-82` `embeddedStructType` | exact |
| `modules/cabana/schema_types.go` (mod: FormField flat keys) | model (DTO) | n/a | existing `Widget`/`Action`/`Fill`/`Path` fields 197-229 | exact |
| `modules/cabana/schema.go` (mod: `assetPath` `$/`) | utility | transform | itself 37-47 | exact |
| `modules/cabana/http.go` (mod) | route | request-response | itself, mount 248-259 + `nestedGet` 299-313 | exact |
| `modules/cabana/admin_openapi.go` (mod) | docs/annotations | n/a | `AdminRelationLink` 575-591 | exact |
| `modules/pact/capabilities.go` (mod) | interface | event-driven hooks | `RelationBeforeLink` 389-392 | exact |
| `modules/conga/scheduler.go` (mod) | scheduler | batch | itself `scheduleEntries` 63-99 | exact |
| `admin/src/components/form/fields/DatepickerField.vue`, `FileuploadField.vue` (new) + `form/registry.ts` (mod) | component | request-response | `fields/DropdownField.vue` + `registry.ts` 10-41 | role-match |
| `admin/src/components/relation/RelationChildModal.vue`, `RelationPivotModal.vue` (new) | component | request-response | `relation/RelationPickerModal.vue` | exact |
| `admin/src/app/sessionKey.ts`, `admin/src/app/dateFormat.ts` (new) | utility | transform | `admin/src/app/listQuery.ts`, `winterUrl.ts` (small pure helpers) | partial |
| Tests (last plan) | test | — | `modules/cabana/relation_test.go`, `modules/lagoon/attach/migrations`+`file_test.go`, `phase09_contract_test.go` (`TestPhase09PermissionMatrix`) | exact |
## Pattern Assignments
### `modules/lagoon/deferred_migrations.go` (migration)
**Analog:** `modules/lagoon/attach/migrations.go` (whole file, 1-48)
```go
var Migrations = []*gormigrate.Migration{
{
ID: "202609180001_create_system_files",
Migrate: func(tx *gorm.DB) error {
stmts := []string{ `CREATE TABLE system_files (...)`, `CREATE INDEX ...` }
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil { return err }
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS system_files").Error
},
},
}
```
Name the var `DeferredBindingMigrations`; ID like `2026100200NN_create_deferred_bindings`; DDL per RESEARCH "Code Examples" (adds `backend_user_id INTEGER NOT NULL`, indexes on master_type/master_field/slave_type/slave_id/session_key).
### `modules/lagoon/migrations.go` (modify `Migrate`)
**Analog:** lines 67-80. Insert after the `summercms.cabana` block, same shape:
```go
admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
if err != nil { return err }
if err := admin.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate backend admin: %w", err)
}
```
Use a new history id (e.g. `"summercms.deferred"`); also wire into `RollbackLast`/status lookups that switch on framework ids.
### `modules/lagoon/date.go` (Date, TimeOfDay)
**Analog:** `modules/lagoon/encrypted.go` Scan 62-93 / Value 97-102:
```go
func (e *Encrypted) Scan(src any) error {
if e == nil { return fmt.Errorf("lagoon: encrypted scan on nil receiver") }
if src == nil { e.plaintext = nil; e.set = false; return nil }
var raw string
switch v := src.(type) {
case string: raw = v
case []byte: raw = string(v)
default: return fmt.Errorf("lagoon: encrypted scan unsupported type %T", src)
}
...
}
func (e Encrypted) Value() (driver.Value, error) { if !e.set { return nil, nil } ... }
```
Add `time.Time` source case for Date (pgx returns time.Time for DATE), plus `MarshalJSON`/`UnmarshalText`/`MarshalText`/`IsZero`. Error prefix `lagoon: date ...`.
### `modules/lagoon/fill.go` / `validate.go`
Edit in place: `convertValue` (fill.go 112-155, pointer path 172-193) gets an `encoding.TextUnmarshaler` fallback only after `ConvertibleTo` fails and only for string/[]byte sources (RESEARCH Pitfall 1). `isEmptyValue` (validate.go 264-277) gets `interface{ IsZero() bool }` (Pitfall 3). Update `modules/lagoon/README.md`.
### `modules/lagoon/deferred.go` (model + store)
**Analog:** `modules/lagoon/attach/file.go` — `TableName()` (line 47), `DeleteForOwner(tx, owner, ownerID, afterCommit)` (130), `DeleteKeys(ctx, bucket, keys)` (157). Store ops take `*gorm.DB` tx as first arg and defer blob deletes via `lagoon.AfterCommit(ctx, db, fn)` (`transaction.go:103`). Never delete blobs inside the tx.
### `modules/lagoon/commands.go` (+ `deferred:purge`)
**Analog:** `RuntimeCommands` lines 16-30:
```go
{
Name: "migrate",
Description: "Run plugin migrations in dependency order",
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return withDB(ctx, app, func(gdb *gorm.DB) error {
if err := Migrate(gdb, plugins); err != nil { return err }
out.Success("migrations applied")
return nil
})
},
},
```
Flags follow `migrate:rollback`'s `[]bonfire.Flag{{Name, Description}}` (lines 33-37). Resolve slave types via `pact.HasModels` over `plugins` (Pitfall 9). Update the "RuntimeCommands returns ..." doc comment and README CLI section; docs checker picks up the name.
### `modules/lagoon/schedule.go` + `modules/conga/scheduler.go`
**Analog:** `conga/scheduler.go` 63-99. Prepend framework entries before the plugin loop using the same compile path:
```go
sched, period, err := scheduleFor(sc.Cadence, loc)
...
out = append(out, scheduleEntry{
id: fmt.Sprintf("%s[%d]:%s", p.ID(), i, sc.Command),
plugin: p.ID(), index: i, cmd: sc, schedule: sched, period: period,
})
```
with plugin id `"summercms.lagoon"`. `FrameworkSchedule(app) []pact.ScheduledCommand` returns `{Command: "deferred:purge", Cadence: pact.DailyAt(h, m)}`.
### `modules/lagoon/attach/store.go`, `guard.go`, `relation.go`
- Body limits: copy `http.MaxBytesReader` usage from `modules/wristband/register.go:67` / `cabana/auth.go:159`; add `io.LimitReader(part, max+1)` per part.
- Pixel ceiling: reuse `maxThumbSourcePixels` (`attach/thumb.go:24-28`).
- Deletion of blobs/thumbs: `blobKeysFor(f)` (file.go 110) + `DeleteKeys`.
- `relation.go`: interface beside `attach.Owner` (file.go 17-22) — `type HasRelations interface { AttachRelations() []Relation }`.
- Guard: no framework analog — port from the application's `classes/image_guard.go` per RESEARCH Pitfall 5 (DetectContentType + image.DecodeConfig, fail closed, webp via `golang.org/x/image/webp` only if already a dependency; check go.mod).
### `modules/cabana/form_schema.go` + `field_date.go` + `field_file.go` (compile)
**Analog:** `compileWidgetKeys` 458-499:
```go
func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error {
if typ != "widget" {
for _, key := range widgetKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is only valid on type: widget", key)
}
}
return nil
}
tag, err := nodeString(values["widget"])
if err != nil || strings.TrimSpace(tag) == "" {
return fmt.Errorf("widget (the custom-element tag) is required on type: widget")
}
field.Widget = tag
...
}
```
Write `compileDatepickerKeys` / `compileFileuploadKeys` identically (key lists `datepickerKeys`, `fileuploadKeys`; `mode` shared, validated per type). Register types in `formFieldTypes`, keys in `formFieldKeys` (22-42). Add `datepicker` to `scalarFormField` (`crud.go:800`). New flat `omitempty` fields on `FormField` (`schema_types.go:197-229`).
### `modules/cabana/deferred.go`, `relation_child.go`, `field_file.go` handlers (tx services)
**Analog:** `RelationService.Link` (`relation.go` 625-692):
```go
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
if err != nil { return err }
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil { return err }
ownerPK := pkUint(parent)
...
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
...
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil {
return lifecycleFailure(cc, err)
}
}
...
})
```
Rules: every child query is parent-scoped (`clause.Eq` + `quotedIdent`), miss returns `recordNotFound{}` → 404 via `writeCRUDError` (crud.go 393-410); validation errors via `relationInvalid(field, msg)`. Commit hook goes in `CRUDService.save` (crud.go 310-386) after `syncBelongsToMany` (~373), before `formAfterCreate/Update` (376-383). Admin id from `bouncer.User(ctx)`.
**Button compile:** extend `compileRelationButtons` (relation.go 314-336) accepted set to `create|update|delete|link|unlink`. **Contract:** add `Kind`, `ForeignKey` to `RelationContract` (27-36), branch in `validateRelationContract` (338-394).
### `modules/cabana/http.go` (routes)
**Analog:** lines 248-259 and `nestedGet` 299-313:
```go
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
constrainRelation(g)
...
case segment == "relations":
s.relationLinked(w, r)
```
Each new route: `g.<Method>(path, requireAjax(handler))` for writes, followed by its `constrain*` call; add `case segment == "files": s.fileList(w, r)` to `nestedGet`. ServeMux conflicts panic in `TestPhase09PermissionMatrix`.
### `modules/cabana/admin_openapi.go`
**Analog:** `AdminRelationLink` (575-591): doc func with `@Summary`, `@Tags admin`, `@Security BackendBearer`, `@Param` per path segment, `@Success 200 {object} Envelope[T]`, `@Failure 401/403/422/404 {object} ErrorEnvelope`, `@Router ... [method]`, body `func AdminX() {}`. Uploads use `@Accept multipart/form-data` and `@Param file_data formData file true`. Regenerate via `scripts/check-admin-openapi.sh`.
### `modules/pact/capabilities.go`
**Analog:** lines 389-392:
```go
// RelationBeforeLink optionally stamps pivot columns before a link insert.
type RelationBeforeLink interface {
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
}
```
New `Relation{Before,After}{Create,Update,Delete}` follow the same one-method optional-interface shape; call sites type-assert `cc.Controller.(pact.X)` and wrap errors with `lifecycleFailure(cc, err)`.
### SPA: `DatepickerField.vue`, `FileuploadField.vue`
**Analog:** `admin/src/components/form/fields/DropdownField.vue` 1-60:
```ts
import type { FormOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: ...] }>()
```
Template binds `:id="controlId"`, `:aria-invalid="invalid ? 'true' : undefined"`, `:aria-describedby="describedBy || undefined"`, `:aria-required`. Register in `admin/src/components/form/registry.ts` (imports 10-20, map entries 32-41: `['datepicker', DatepickerField]`, `['fileupload', FileuploadField]`). Visuals per 12.2-UI-SPEC.md; Reka DatePicker/DateField + `@internationalized/date`.
### SPA: `RelationChildModal.vue`, `RelationPivotModal.vue`
**Analog:** `admin/src/components/relation/RelationPickerModal.vue` 1-50:
```ts
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { api } from '../../api/client'
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import { showToast } from '../../state/useToasts'
const props = defineProps<{ open: boolean; source: ControllerParams; recordId: number; relation: string; schema: RelationSchema }>()
const emit = defineEmits<{ 'update:open': [open: boolean]; linked: [count: number]; closed: [] }>()
let generation = 0 // stale-response guard
```
Wire into `RelationManager.vue`; remove create-screen drop in `FormView.vue:71-77` only for `deferrable` relations. Rebuild `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`).
## Shared Patterns
- **Transactions:** `lagoon.Transaction` → `withTx` → `loadRecord` (FOR UPDATE, applies `pact.FormExtendQuery`; crud.go 441-461). Apply to all child/file/commit mutations.
- **After-commit side effects:** `lagoon.AfterCommit(ctx, db, fn)` (`transaction.go:103`) + `attach.DeleteKeys`. Apply to every blob delete.
- **Error envelope:** `recordNotFound{}` → 404, `relationInvalid(field,msg)` → 422, `lifecycleFailure(cc, err)` for hook errors; mapped by `writeCRUDError` (crud.go 393-410). Foreign child = 404, never 403.
- **Write routes:** `requireAjax(...)` + `constrain*` after each `g.Post/Put/Delete` (http.go 248-259).
- **Body caps:** `http.MaxBytesReader` (cabana raw routes have no surf limit — `surf/bodylimit.go:29-33`).
- **Boot-time contract checks:** type-assert capability, fail `cabana.Activate` with descriptive error (relation.go `validateRelationContract` 338-394).
- **Docs rule:** every exported API/config/CLI change updates module `README.md` and `docs/` (`docs/backend/relation-manager.md`) in the same change; `go test ./cmd/summer -run TestDocsTree`.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `modules/lagoon/attach/guard.go` | utility | transform | Image guard exists only in an application plugin; port per RESEARCH Pitfall 5 |
| `admin/src/app/sessionKey.ts`, `dateFormat.ts` | utility | transform | No crypto/date helpers in SPA; use RESEARCH (crypto.getRandomValues base64url; PHP-format token table, Pitfall 13). Shape like `admin/src/app/listQuery.ts` (pure exported functions) |
## Metadata
**Analog search scope:** modules/lagoon, modules/lagoon/attach, modules/cabana, modules/conga, modules/pact, modules/surf, modules/wristband, admin/src/components, admin/src/app
**Files scanned:** ~30
**Pattern extraction date:** 2026-10-02