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.
253 lines
16 KiB
Markdown
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
|