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

16 KiB

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)

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:

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:

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:

{
	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:

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:

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):

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:

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:

// 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:

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:

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