docs(35): pattern map and planning-time decisions D-22..D-25

This commit is contained in:
Jakub Zych
2026-11-06 19:10:23 +01:00
parent 9232dc1048
commit a0242808a0
2 changed files with 288 additions and 0 deletions

View File

@@ -91,6 +91,12 @@ Not in this phase: mail driver/SMTP settings screens, MailBlocker, newsletters/b
The postcard and cabana READMEs and the affected `docs/` pages are updated in the same commits as API/config/permission changes. The `admin/` changes regenerate `admin/openapi/admin.json` + `schema.d.ts` (`scripts/check-admin-openapi.sh`) and rebuild the committed `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`). The postcard and cabana READMEs and the affected `docs/` pages are updated in the same commits as API/config/permission changes. The `admin/` changes regenerate `admin/openapi/admin.json` + `schema.d.ts` (`scripts/check-admin-openapi.sh`) and rebuild the committed `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`).
### Planning-time amendments (2026-11-06, confirmed by the user after research)
- **D-22:** postcard, not cabana, owns the mail tables, their migrations (a framework set in `lagoon.migrationSets`, like `attach.Migrations`) and the per-send override lookup, because `queue:work` sends mail in a process where cabana never activates. cabana still owns the screens, permissions and controllers (D-01 otherwise unchanged).
- **D-23:** Every file template, layout and partial gets an index row (Winter `syncAll` style), synced from the file catalog at serve start. Reset (D-06) clears the override columns on that row instead of deleting it; custom layouts are still deleted.
- **D-24:** The Branding logo's absolute URL is built at send time from config (`storage.uploads.public_url` / `app.url`) through a config-based attach helper, not stored at save.
- **D-25:** postcard ships no built-in template; the templates list keeps the UI-SPEC empty-state text.
### Claude's Discretion ### Claude's Discretion
- Table names and schema (one overrides table per kind, or a shared one), as long as it is Postgres/GORM with gormigrate migrations owned by the cabana pseudo-plugin, or by postcard if the planner justifies it. - Table names and schema (one overrides table per kind, or a shared one), as long as it is Postgres/GORM with gormigrate migrations owned by the cabana pseudo-plugin, or by postcard if the planner justifies it.
- Exact partial call syntax and the built-in partial set beyond button/panel/table. - Exact partial call syntax and the built-in partial set beyond button/panel/table.

View File

@@ -0,0 +1,282 @@
# Phase 35: Mail templates and layouts editor - Pattern Map
**Mapped:** 2026-11-06
**Files analyzed:** 27 (new + modified, from 35-RESEARCH.md "Recommended Project Structure" and Wave 0 gaps)
**Analogs found:** 24 / 27
Line numbers are from master at `9232dc1`. Phase 33 is editing cabana at the same time, so re-check `form_schema.go`, `http.go` and `crud.go` line numbers before executing.
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `modules/postcard/store.go` (new: Migrations, tables, OverrideSource, index sync) | migration + service | CRUD / batch sync | `modules/lagoon/attach/migrations.go` + `modules/lagoon/ondatabase.go` | exact (migrations), role-match (sync) |
| `modules/postcard/mailer.go` (mod: Activate wires DB source, Send lookup + fallback) | service | request-response | itself, lines 92-109 | exact |
| `modules/postcard/templates.go` (mod: partials, samples, hashes, alias fallback) | service | transform | itself | exact |
| `modules/postcard/partials.go` (new) | utility | transform | `modules/postcard/templates.go` (execHTML + goldmark, lines ~343-372) | role-match |
| `modules/postcard/samples.go` (new) | utility | transform | `modules/postcard/templates.go` parseINI / parseTemplateFile | role-match |
| `modules/postcard/brand.go` (new) | utility | transform | `modules/postcard/templates.go` layout data (lines 358-372) | partial |
| `modules/postcard/editor.go` (new: exported read + Validate/Preview/SendDraft) | service | request-response | `modules/postcard/mailer.go` Send | role-match |
| `modules/postcard/assets/{system,minimal}.htm, partials/*.htm, brand.css.tmpl, sample.htm` | config/asset | file-I/O | `modules/postcard/assets/default.htm` | exact |
| `modules/pact/capabilities.go` (mod: HasMailPartials, RowStateCustomized/Outdated) | config (interface) | n/a | itself, lines 1055-1062 and ~1271 | exact |
| `modules/lagoon/migrations.go` (mod: add postcard set) | migration registry | batch | itself, lines 225-230 | exact |
| `modules/cabana/backend_screens.go` (mod: mail controllers, permissions, settings items) | provider (pseudo-plugin) | n/a | itself, lines 76-117 | exact |
| `modules/cabana/mail_admin.go` (new: models, controllers, hooks, record actions) | controller | CRUD | `modules/cabana/backend_users_admin.go` | exact |
| `modules/cabana/mail_routes.go` (new: preview, test send, image library) | controller (route) | request-response | `modules/cabana/field_markdown.go` (markdownPreview) + `http.go:342` | exact |
| `modules/cabana/field_codeeditor.go` etc. (codeeditor, colorpicker, mailimage, mailpreview) | config (field registry) | n/a | `modules/cabana/form_schema.go:24-51` | exact |
| Branding settings model (in mail_admin.go or settings registration) | model | CRUD singleton | `modules/cabana/settings.go` (lines 96-102, 186, 236-277) | exact |
| Image library host (AdminSingleton) | controller | CRUD singleton | `modules/cabana/singleton.go:41-80` | exact |
| `modules/cabana/backendscreens/controllers/mail{templates,layouts,partials,images}/config_{form,list,filter}.yaml` + `_*.htm` | config | n/a | `modules/cabana/backendscreens/controllers/users/*` | exact |
| `modules/cabana/backendscreens/models/mail*/{fields,columns}.yaml` | config | n/a | `modules/cabana/backendscreens/models/user/*` | exact |
| `admin/src/components/form/fields/CodeEditorField.vue` | component | event-driven | `admin/src/components/form/fields/RichEditorField.vue` (lazy editor) + `MarkdownField.vue` | role-match |
| `admin/src/components/form/fields/MailPreviewField.vue` | component | request-response | `admin/src/components/form/fields/MarkdownField.vue` | exact |
| `admin/src/components/form/fields/ColorPickerField.vue` | component | event-driven | `admin/src/components/form/fields/TextField.vue` | role-match |
| `admin/src/components/form/fields/MailImageField.vue`, `admin/src/components/mail/MailImagePicker.vue` | component | request-response | `admin/src/components/form/fields/FileuploadField.vue` | role-match |
| `admin/src/components/form/registry.ts` (mod) | config | n/a | itself, lines 36-90 | exact |
| `admin/src/components/form/codeeditor/editor.ts` | utility | event-driven | none (CodeMirror is new) | no analog |
| `admin/src/components/mail/insertImage.ts` | utility | transform | none | no analog |
| Go tests (`modules/postcard/*_test.go`, `modules/cabana/mail_*_test.go`) | test | n/a | `modules/cabana/administrators_harness_test.go:154`, `lagoon/postgres_test.go` | exact |
| `admin/tests/form/{codeeditor,mailpreview,insertImage}*.test.ts`, `admin/tests/browser/codeeditor.test.ts` | test | n/a | existing `admin/tests/form/*` markdown tests | role-match |
## Pattern Assignments
### `modules/postcard/store.go` (migration + DB-backed source)
**Analog:** `modules/lagoon/attach/migrations.go` (lines 1-40). Raw SQL statements inside a gormigrate migration, ID prefixed with a date stamp:
```go
var Migrations = []*gormigrate.Migration{
{
ID: "202609180001_create_system_files",
Migrate: func(tx *gorm.DB) error {
stmts := []string{
`CREATE TABLE system_files (
id SERIAL PRIMARY KEY,
...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX system_files_field_index ON system_files (field)`,
}
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil {
return err
```
Use the schema in 35-RESEARCH.md "Recommended schema" (Winter-style table names). Wire the index sync through `lagoon.OnDatabase` (`modules/lagoon/ondatabase.go:19-25`):
```go
func OnDatabase(app *backpack.App, fn func(sqlDB *sql.DB, gdb *gorm.DB) error) error
```
It runs immediately if the DB is already published, otherwise when Publish runs. Because lagoon imports postcard (for the migration set), postcard must not import lagoon. Look the `*gorm.DB` up through `backpack` (`app.Lookup`) at send time, or take the source through an interface that postcard defines (D-01).
### `modules/lagoon/migrations.go` (modified)
**Analog:** itself, `migrationSets` (lines 220-230):
```go
sets := []migrationSet{
{"summercms.attach", "system_files", attach.Migrations},
{"summercms.cabana", "backend admin", BackendAdminMigrations},
{DeferredHistoryID, "deferred bindings", DeferredBindingMigrations},
{QueueHistoryID, "queue", QueueMigrations(sqlDB)},
}
```
Add `{"summercms.postcard", "mail", postcard.Migrations},` and update the set list in `modules/lagoon/pending_test.go`.
### `modules/postcard/mailer.go` (modified)
**Analog:** itself, `Activate` lines 92-109:
```go
func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error {
...
m := &mailer{catalog: NewCatalog(), driver: driver, opts: opts}
if err := app.Publish[Mailer](m); err != nil {
return fmt.Errorf("postcard: %w", err)
}
return nil
}
```
Do not change the `Mailer` interface: `../sm-paymentgateway-plugin/mail_smoke_test.go:56` implements it. Add the override lookup inside `(*mailer).Send`. Treat any lookup error as "no overrides" and log a rate-limited warning (Pitfall 7).
### `modules/pact/capabilities.go` (modified)
**Analog:** itself. Row states at lines 1055-1062:
```go
const (
RowStateDeleted RowState = "deleted"
RowStateNegative RowState = "negative"
RowStateDisabled RowState = "disabled"
)
```
Append `RowStateCustomized` and `RowStateOutdated` in the same style, and update `TestRowStateValues` and cabana's `rowStateOrder`. `HasMailTemplates` (lines ~1270-1275) stays as it is. Put `HasMailPartials` next to it, in the same three-method `fs.FS` + names shape.
### `modules/cabana/backend_screens.go` (modified)
**Analog:** itself, lines 76-117. Add exported permission and controller ID constants next to `PermissionManageUsers`/`AdministratorsControllerID` (lines 14-28). Extend:
```go
func (s *backendScreens) AdminControllers() []pact.AdminController {
return []pact.AdminController{usersAdmin{screens: s}, rolesAdmin{screens: s}}
}
func (*backendScreens) Permissions() []pact.Permission {
return []pact.Permission{
{Code: PermissionManageUsers, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.manage_users"},
```
Settings items follow the `pact.SettingsItem{Code, Label, Description, Category: "backend::lang.settings.category_system", Icon, Order, Permissions, Controller}` shape (lines 96-115). No role grants in code (D-03 / 19.6.1 D-04). The YAML is embedded through `//go:embed all:backendscreens` (line 38), so the new `controllers/mail*/` and `models/mail*/` directories are picked up with no extra wiring.
### `modules/cabana/mail_admin.go` (controller, CRUD)
**Analog:** `modules/cabana/backend_users_admin.go`.
**Struct + interface assertions** (lines 24-53):
```go
type usersAdmin struct {
screens *backendScreens
}
var (
_ pact.AdminController = usersAdmin{}
_ pact.AdminPermissioned = usersAdmin{}
_ pact.ListRowStates = usersAdmin{}
_ pact.FormBeforeUpdate = usersAdmin{}
_ pact.FormBeforeDelete = usersAdmin{}
_ pact.HasAdminRecordActions = usersAdmin{}
_ pact.AdminPartialData = usersAdmin{}
...
)
```
**Identity methods** (lines 55-63): `ID()`, `ModelName()`, `ConfigDir() "controllers/users"`, `RequiredPermissions()`, `NewRecord()`.
**Helpers to copy:** `db()` (109), `requestDB(ctx)` (122), `translator()` (130), `asBackendUser` type assertion (140).
**Row states** (178-193): Customized when `is_custom`, Outdated when `is_custom && base_hash != file_hash`.
```go
func (usersAdmin) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
out := make([][]pact.RowState, len(records))
for i, record := range records {
user, err := asBackendUser(record)
if err != nil { return nil, err }
if user.DeletedAt.Valid { out[i] = append(out[i], pact.RowStateDeleted) }
...
```
**Hooks:** FormBeforeDelete (199) is the model for refusing deletion of an in-use layout (use `ForbiddenError`, Pitfall 9). FormRules (475) handles save validation. Record actions (325) cover Reset to default and Test send. PartialData (422) feeds the server-rendered `_info.htm` / drift partials. Parameterized messages are built with `backendMessageParams(ctx, tr, key, fallback, params)` (`account_tokens.go:206,227`), not with `localizeDetails`.
### `modules/cabana/mail_routes.go` (route, request-response)
**Analog:** `modules/cabana/field_markdown.go` lines 55-80 plus mounting at `modules/cabana/http.go:342`:
```go
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
```
```go
type AdminMarkdownPreviewRequest struct { Markdown string `json:"markdown"` }
type AdminMarkdownPreviewResult struct { HTML string `json:"html"` }
func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
if !tokenRouteGate(principal, w) { return }
var body AdminMarkdownPreviewRequest
```
Add an exported `AdminMail*Request/Result` pair per route, swag annotations, and a per-`mode` permission check. The 422 responses use fixed message constants (`msgMarkdownPreviewRefused` style) and never echo the renderer's error. Rate-limit test send with `cooler.Attempter` (`modules/cooler/cooler.go:68-73`). Afterwards run `scripts/check-admin-openapi.sh`.
### New field types (`form_schema.go` + `field_codeeditor.go`)
**Analog:** `modules/cabana/form_schema.go` lines 24-51:
```go
formFieldTypes = map[string]struct{}{
"text": {}, ... "richeditor": {}, "repeater": {}, "money": {}, "taglist": {},
}
formFieldKeys = map[string]struct{}{ ... "mode": {}, ... "separator": {}, "customTags": {}, }
```
Add `codeeditor`, `colorpicker`, `mailimage`, `mailpreview` to the types, and `language` and `mailImages` to the keys. If the Branding settings form uses colorpicker, add it to the settings refusal check at `settings.go:102` only for types that need to be refused.
### Branding settings model
**Analog:** `modules/cabana/settings.go`. Fields are decoded with `decodeFields(raw, types.refusing())` (line 96). The refused built-ins are `widget/partial/fileupload/permissioneditor` (line 102), so a Branding logo upload cannot use `fileupload` there and needs a `mailimage` URL field instead. The singleton row sits at PK 1 (line 186), and the first Put creates it (lines 236-277). Example registration: `modules/cabana/example_settings_test.go`.
### Image library host
**Analog:** `modules/cabana/singleton.go` lines 41-80. Implement `pact.AdminSingleton.SingletonRecord(ctx, db) (any, error)` with first-or-create. cabana already retries on a unique-index race (`uniqueViolation(err)` → `singletonRace{}`). Absolute URLs come from `attach.PublicURL` (`lagoon/attach/thumb.go:80-96`) and are prefixed with `app.url` when no `public_url` is set.
### YAML controllers and models
**Analog:** `modules/cabana/backendscreens/controllers/users/config_form.yaml`:
```yaml
name: backend::lang.user.name
form: ~/plugins/summercms/cabana/models/user/fields.yaml
modelClass: Backend\Models\User
defaultRedirect: summercms/cabana/users
create:
redirect: summercms/cabana/users/preview/:id
redirectClose: summercms/cabana/users
update:
redirect: summercms/cabana/users/update/:id
redirectClose: summercms/cabana/users/preview/:id
preview:
headerPartial: hint
recordActions: [activate, deactivate, restore]
messages: { create: ..., update: ..., saved: ..., deleteConfirm: ..., deleted: ..., edit: ..., preview: ... }
```
The siblings `config_list.yaml`, `config_filter.yaml` and `_hint.htm` cover lists, the Customized/Outdated filters and server partials. The partial allowlist is in `partial_render.go:105-118`.
### `admin/src/components/form/registry.ts` (modified)
**Analog:** itself. Add an import block (lines 38-62) and an entry in the `renderers` map (lines 79-90+):
```ts
const renderers = new Map<string, Component>([
['text', TextField],
...
['fileupload', FileuploadField],
['datepicker', DatepickerField],
```
Add `mailpreview` to `valueless` (`new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])`) and the group-labelled types to `groupLabelledTypes`. Extend the phase-history header comment (lines 1-35) with a Phase 35 paragraph.
### `MailPreviewField.vue` / `CodeEditorField.vue`
**Analog:** `admin/src/components/form/fields/MarkdownField.vue` lines 1-70:
```ts
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { api } from '../../../api/client'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const PREVIEW_DEBOUNCE_MS = 300
let sequence = 0 // drops stale answers: if (current !== sequence) return
...
const { data, error } = await api.POST('/markdown/preview', { body: { markdown: text.value } })
if (data) { sanitizedHtml.value = data.data.html } else { previewError.value = refusal(error) }
```
For the preview, copy the debounce, the sequence guard and the `refusal()` details extraction. Render into a `sandbox` iframe `srcdoc`, which the CSP comment allows. Never `v-html` the mail. For CodeEditorField, copy the lazy-import pattern of `RichEditorField.vue` (TipTap loaded on demand) to load `codeeditor/editor.ts`. Field components import only from `../control`, never from `registry.ts` (registry.ts lines 64-67, to avoid an import cycle). Keep Mod+Enter reaching the form (Pitfall 5).
### Go tests
**Analog:** `modules/cabana/administrators_harness_test.go:154` `newScreensEnv(t, extra ...party.Plugin)`. It builds compass config with the admin secret, `lagoon.Publish(app, adminSQL, gdb)`, phrasebook and `surf.Assemble`. Reuse it for `TestMailPermissions`, routes and lifecycle. For postcard Postgres tests, copy the TestMain from `modules/lagoon/postgres_test.go`, which uses `flag.Parse()` and `testing.Short()` per CLAUDE.md. Smoke-test naming follows `backend_screens_smoke_test.go`.
## Shared Patterns
### Backend auth gate on custom routes
**Source:** `modules/cabana/field_markdown.go:72-79` (`bouncer.User` + `principal.Backend` + `tokenRouteGate`). Mount with `requireAjax` for POSTs (`http.go:342`).
**Apply to:** all mail_routes.go handlers, plus a per-permission check against `PermissionManageMail*`.
### Error responses
**Source:** `WriteError(w, status, code, msgConst)` with fixed `msg*` constants (`field_markdown.go:53,74`). Use `ForbiddenError` for refusals and translated params via `backendMessageParams`.
**Apply to:** routes and hooks.
### HTML safety gate
**Source:** `modules/postcard/templates.go:25-26` and `modules/cabana/field_markdown.go:16-18,37-48` (identical regexes). Reuse them unchanged for draft validation, but report their false positives to the user (Pitfall 8).
### Embedded pseudo-plugin assets
**Source:** `backend_screens.go:35-74` (`//go:embed all:backendscreens`, `fs.Sub`).
### Docs/README and generated artifacts
Update READMEs for postcard and cabana in the same commit. Then run `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` and `summer docs:build --check`.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `admin/src/components/form/codeeditor/editor.ts` | utility | event-driven | CodeMirror 6 is new. Use 35-RESEARCH.md "CodeMirror setup" (verified export names). Needs a STACK.md row and a decision note before install. |
| `admin/src/components/mail/insertImage.ts` | utility | transform | No Markdown-insertion helper exists. Use the RESEARCH Pitfall 4 rules (absolute URL, percent-encoding). |
| `modules/postcard/samples.go` placeholder walker | utility | transform | No `text/template/parse` walker exists in the repo. Use the RESEARCH "Placeholder walker" prototype. |
## Metadata
**Analog search scope:** modules/postcard, modules/cabana (+ backendscreens), modules/lagoon (+ attach), modules/pact, admin/src/components/form
**Files scanned:** ~20
**Pattern extraction date:** 2026-11-06