Files
summercms/.planning/phases/10-admin-vue-spa/10-02-SUMMARY.md
2026-09-27 16:30:15 +02:00

323 lines
20 KiB
Markdown

---
phase: 10-admin-vue-spa
plan: 02
subsystem: admin
tags: [cabana, phrasebook, relations, openapi, messages, toolbar, filters, conformance]
requires:
- phase: 10-admin-vue-spa
plan: 01
provides: backend.uri prefix, cookie + CSRF transport, typed envelopes, admin OpenAPI pipeline, fonoteka lang catalog
provides:
- relation field options endpoint and relation values in saves, with labels (D-17, D-18) and the read-only protected-key relation (D-26)
- backend::lang framework strings (pl, en), the HasLangOverrides layer and the public GET /lang bundle (D-20)
- messages blocks on list, form and relation configs, served as CLDR forms (D-13, D-24)
- declarative toolbar.buttons list (D-14)
- model-backed filter choices endpoint (D-27)
- a fully typed admin OpenAPI document proven against real handler output (D-15, D-16)
- fonoteka contracts, YAML copy and toolbars for the five controllers (D-08)
affects: [10-03, 10-04, 10-05]
actuals:
tokens: 48677 # chars/4 over added lines in both repos; excludes the generated admin.json and schema.d.ts
tasks: 3
commits: 3 # MEASURED summercms.go: git rev-list --count e9b48d4..HEAD (fonoteka.go: 4, bb4cd7a..cb6727f)
plan_head_before: e9b48d472013811b526cd0be8d3c378f1371d5d4
plan_head_after: 9f296b0484174cf180906140c646a8578d3568ad
app_plan_head_before: bb4cd7a17584fe52f43c9884ec555e319431c12f
app_plan_head_after: cb6727f3c284265d6254c42e8c00db3f7ab4655e
tech-stack:
added: []
patterns:
- Controller-declared FieldRelationContract (belongsTo foreign key or belongsToMany pivot) validated at activation; framework code never names a table or key
- One scoped query (RelationExtendOptionsQuery) serves options and revalidates submitted ids inside the save transaction
- Messages cached as phrase keys and resolved per request into CLDR form maps; framework defaults fill omitted keys
- One six-segment GET pattern dispatches logical routes that ServeMux cannot register side by side
- External conformance test drives every inventoried route through surf.Assemble and decodes into the documented type with DisallowUnknownFields
key-files:
created:
- cabana/relation_field.go
- cabana/relation_field_test.go
- cabana/messages.go
- cabana/messages_test.go
- cabana/lang.go
- cabana/filter_options_test.go
- cabana/openapi_conformance_test.go
- cabana/export_test.go
- phrasebook/backend/lang/pl/lang.yaml
- phrasebook/backend/lang/en/lang.yaml
- phrasebook/phase10_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go
modified:
- pact/capabilities.go
- phrasebook/lang.go
- phrasebook/loader.go
- phrasebook/translator.go
- cabana/crud.go
- cabana/http.go
- cabana/registry.go
- cabana/contracts.go
- cabana/relation.go
- cabana/form_schema.go
- cabana/list_schema.go
- cabana/filter_schema.go
- cabana/schema_types.go
- cabana/admin_openapi.go
- cabana/auth.go
- internal/build/stubs/artifacts.tmpl
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/types.ts
- admin/src/styles/main.css
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/*/config_list.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/*/config_form.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml
- ../fonoteka.go/plugins/golem15/fonoteka/lang/{pl,en}/lang.yaml
key-decisions:
- "Relation lists, field options and filter options share one mounted pattern GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name} dispatched by service.nestedGet, because ServeMux rejects fields/{field}/options next to {id}/relations/{name}; the documented D-17/D-27 paths are unchanged"
- "Relation ids are parsed in submitted order with duplicate detection (the asUint coercion normalizeIDs uses), since normalizeIDs sorts and dedups; only JSON integers are accepted"
- "Record responses put relation values in data (belongsTo id or null, belongsToMany ids in pivot order) and labels in meta.labels; the read-only Collections owner appears as data.owner with its email label, owner_id stays hidden"
- "Show and save labels read the related model without the options hook, so a stored row outside the current scope still shows its label"
- "Message key validation at activation checks declared keys and framework defaults; defaults derived from toolbar.search.prompt, noRecordsMessage or the form name may be literal Winter text and are not checked"
- "Cached list and relation schemas carry each message key as its own form; responses resolve them in the request locale; form create defaults to config_form name, list searchPrompt and empty default to the list's prompt and noRecordsMessage"
- "GET /lang resolves meta.locale to the first locale of the fallback chain the catalog has backend strings for (de falls back to en)"
- "Filter options: {scope} in the path is the filter name (the filter[<name>] key); the model's FilterOptions receives the filter's scope method name"
- "Create is documented as 201 RecordEnvelope, matching the handler; every protected route documents 401, 403 and 404, writes 422, bulk delete 409"
- "Tailwind skips the generated schema.d.ts and openapi/ so admin API changes do not churn boardwalk/dist; the committed dist is unchanged"
patterns-established:
- "A route inventory entry may name its mounted pattern (adminRoute.mounted) when several logical routes share one ServeMux pattern"
- "Every admin route needs a TestPhase10OpenAPIConformance case; a new route without one fails the test"
requirements-completed: [ADMIN-06]
coverage:
- id: D1
description: "Relation options: scoped by the hook, case-insensitive escaped search, label then id order, 20/100 paging, numeric values, 403 before SQL, 404 for non-relation and read-only fields"
requirement: ADMIN-06
verification:
- kind: integration
ref: "cabana/relation_field_test.go#TestPhase10RelationOptions"
status: pass
- kind: unit
ref: "cabana/relation_field_test.go#TestPhase10NestedGetDispatch"
status: pass
human_judgment: false
- id: D2
description: "Relation saves: belongsTo FK, ordered pivot replace with order column, absent keys untouched, null clears, labels in meta; forged, out-of-scope, non-integer and duplicate ids are 422 and roll back everything"
requirement: ADMIN-06
verification:
- kind: integration
ref: "cabana/relation_field_test.go#TestPhase10RelationSave"
status: pass
- kind: integration
ref: "cabana/relation_field_test.go#TestPhase10RelationForgedID"
status: pass
- kind: unit
ref: "cabana/relation_field_test.go#TestPhase10RelationBoot"
status: pass
human_judgment: false
- id: D3
description: "fonoteka albums genre/artists and the read-only collections owner through /plytadmin with the cookie"
requirement: ADMIN-06
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go#TestPhase10AlbumRelations"
status: pass
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_relations_test.go#TestPhase10CollectionOwnerReadOnly"
status: pass
human_judgment: false
- id: D4
description: "backend::lang strings, CLDR form conversion, override layer, public bundle, SPA keys resolve in pl and en"
requirement: ADMIN-06
verification:
- kind: unit
ref: "phrasebook/phase10_test.go#TestPhase10Forms"
status: pass
- kind: unit
ref: "phrasebook/phase10_test.go#TestPhase10LangOverride"
status: pass
- kind: unit
ref: "phrasebook/phase10_test.go#TestPhase10SPAKeysResolve"
status: pass
- kind: unit
ref: "cabana/messages_test.go#TestPhase10Bundle"
status: pass
human_judgment: false
- id: D5
description: "messages blocks with defaults and plural forms, boot failures on unknown or missing keys; declarative toolbar with boot errors"
requirement: ADMIN-06
verification:
- kind: unit
ref: "cabana/messages_test.go#TestPhase10Messages"
status: pass
- kind: unit
ref: "cabana/messages_test.go#TestPhase10Toolbar"
status: pass
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_copy_test.go#TestPhase10ControllerCopy"
status: pass
- kind: unit
ref: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_auth_test.go#TestPhase10LangCatalog"
status: pass
human_judgment: false
- id: D6
description: "Model-backed filter choices behind the controller permission; activation fails without FilterOptions"
requirement: ADMIN-06
verification:
- kind: unit
ref: "cabana/filter_options_test.go#TestPhase10FilterOptions"
status: pass
human_judgment: false
- id: D7
description: "Every admin route is typed in admin/openapi/admin.json and its real response decodes into the documented type"
requirement: ADMIN-06
verification:
- kind: integration
ref: "cabana/openapi_conformance_test.go#TestPhase10OpenAPIConformance"
status: pass
- kind: other
ref: "scripts/check-admin-openapi.sh --check"
status: pass
- kind: other
ref: "npm --prefix admin run typecheck; scripts/check-admin-dist.sh"
status: pass
human_judgment: false
- id: D8
description: "The five fonoteka controllers serve exactly their tracked YAML with built-in field types; created records read back with the same keys"
requirement: ADMIN-06
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/fonoteka/admin_phase10_controllers_test.go#TestPhase10Controllers"
status: pass
human_judgment: false
duration: 50min
completed: 2026-09-27
status: complete
---
# Phase 10 Plan 02: Admin backend contract Summary
**The cabana admin API now serves everything the SPA screens need: relation choices and relation saves with labels (read-only protected owner), framework backend::lang strings with an override layer and a public bundle, per-controller messages as CLDR plural forms, a declarative [create, delete] toolbar, model-backed filter choices, and an OpenAPI document whose every route is proven against the real handler output.**
## Performance
- **Duration:** 50 min
- **Started:** 2026-09-27T13:38:29Z
- **Completed:** 2026-09-27T14:28:07Z
- **Tasks:** 3
- **Files modified:** 36 in summercms.go, 27 in fonoteka.go
## Accomplishments
- **Relations (D-17, D-18, D-26).** A controller declares `AdminFieldRelations()` with a belongsTo foreign key or a belongsToMany pivot (optional order column, label column). Activation rejects a relation field without a contract, a missing column, an unknown kind, duplicates and orphans, naming plugin, controller and field. `GET .../fields/{field}/options` pages `{value, label}` choices through the controller's `RelationExtendOptionsQuery`. On save, present relation keys are revalidated through that same scoped query after the Before hook (422 and a full rollback otherwise). belongsTo sets the key before the row write, and belongsToMany deletes and bulk-inserts pivot rows in submitted order. Show, create and update return the values in `data` and the labels in `meta.labels`. A belongsTo on a protected fill key (Collections `owner_id`) is served `readOnly`, has no options endpoint and is never written.
- **Strings (D-20).** `phrasebook/backend/lang/{pl,en}/lang.yaml` carries the framework admin copy, including every key the SPA already uses, with CLDR plural maps. It loads as namespace `backend`. `pact.HasLangOverrides` trees can replace keys and add locales. Activation fails when a `backend::` key cannot convert to plural forms. The public `GET /lang` returns only `backend::lang.*` for the request locale over the fallback, with `Cache-Control: no-cache`.
- **Copy and toolbar (D-13, D-14, D-24).** `messages:` blocks on list, form and relation configs are decoded strictly. Omitted keys take framework defaults, every response carries a complete messages object of CLDR form maps, and activation fails on a missing phrase key. `toolbar.buttons` is an ordered `[create, delete]` list. The Winter string form, duplicates, unknown actions and `delete` without checkboxes fail at boot, and `create` is dropped when there is no form. The form schema also serves the raw Winter redirects.
- **Filters (D-27).** A scope filter's model must implement `pact.FilterOptions`. `GET .../filters/{scope}/options` serves localized choices behind the controller permission.
- **Typed API (D-15, D-16).** Every route documents a concrete success schema and its error responses, and `SuccessEnvelope` is gone. `TestPhase10OpenAPIConformance` drives all 25 inventoried routes through the assembled router on PostgreSQL and decodes each body with unknown fields disallowed. It fails when a route has no case.
- **fonoteka.** The albums controller has genre and artists contracts, and the collections controller has a read-only owner. All five controllers declare `[create, delete]` and messages, and the editors relation has its own copy. The new keys come with Polish plurals. Assembled tests cover relations, the read-only owner, Polish copy and the YAML-exact schemas.
## Task Commits
1. **Task 1 (tracer): relation options and saves with labels** - `fe04dbc` (feat, summercms.go), `2914892` (feat, fonoteka.go). Preceded by `84dbda1` (test, fonoteka.go), which repairs pre-existing Phase 9 test failures (see Deviations).
2. **Task 2: backend strings, messages, declarative toolbar** - `c87148a` (feat, summercms.go), `648bff6` (feat, fonoteka.go)
3. **Task 3: filter choices, full typing, conformance** - `9f296b0` (feat, summercms.go), `cb6727f` (test, fonoteka.go)
**Plan metadata:** recorded in the docs commit that adds this file.
Tracer gate (Task 1): the Task 1 `<verify>` was re-run end to end after its commits and passed (human_verify_mode end-of-phase, automated verify), then Task 2 started.
## Decisions Made
See `key-decisions` in the frontmatter. The most consequential one is the shared six-segment GET pattern. It keeps the D-17 and D-27 paths exactly as decided without touching the Phase 9 relation route.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] ServeMux conflict between the new options routes and the relation list route**
- **Found during:** Task 1
- **Issue:** `GET {api}/{vendor}/{plugin}/{controller}/fields/{field}/options` and the Phase 9 `GET .../{id}/relations/{name}` both match paths like `.../fields/relations/options`, and neither is more specific. Go's ServeMux panics at registration (verified with a probe). The same applies to `filters/{scope}/options`.
- **Fix:** one mounted pattern `GET .../{id}/{segment}/{name}` with identifier constraints, dispatched by `service.nestedGet` on the literal segments. A numeric id never equals `fields` or `filters`, and anything else is the JSON `not_found` envelope. The route inventories in both repos gained `mounted` keys, and the OpenAPI document keeps the three logical paths.
- **Files:** `cabana/http.go`, `cabana/security_coverage_test.go`, `../fonoteka.go/.../admin_phase09_security_test.go`
- **Commits:** `fe04dbc`, `2914892`, `9f296b0`
**2. [Rule 3 - Blocking] Twelve fonoteka plugin tests were already failing before this plan**
- **Found during:** Task 1 (the plan's verify runs `TestAlbumsAdmin.*` and `TestCollectionsAdmin.*`)
- **Issue:** On untouched HEAD copies of both repos, `go test` in `plugins/golem15/fonoteka` failed 12 tests. Form and list tests pinned raw phrase keys and identical pl/en bodies, which the 10-01 lang catalog now translates. The /me route-isolation check also flagged the backend admin `/plytadmin/api/v1/auth/me`. The fonoteka root `go test ./...` does not include the plugin modules, so 10-01 did not see these failures.
- **Fix:** those tests now pin the resolved en labels plus a localized pl body, and the isolation check allows the admin profile route. The tracer genre list searches for its own row, because genres created by other tests can push it past page 1.
- **Commits:** `84dbda1` (artists, genres, styles, tracer, oauth tools); album and collection pins are in `2914892`
- **Result:** every fonoteka plugin module package now passes.
**3. [Rule 2 - Correctness] jsonScalar and fieldContext had no JSON decoders**
- **Found during:** Task 3 (conformance test)
- **Fix:** `UnmarshalJSON` for both, accepting exactly their served shapes, so a FormView decodes back into its documented type.
- **Commit:** `9f296b0`
**4. [Rule 3 - Blocking] boardwalk/dist drifted on API-only changes**
- **Found during:** Task 3 (`check-admin-dist.sh`)
- **Issue:** Tailwind v4 scanned `schema.d.ts` and `openapi/admin.json`, so new description prose produced new utility classes.
- **Fix:** `@source not` for both in `admin/src/styles/main.css`. The fresh build equals the committed dist again, so no dist change was needed.
- **Commit:** `9f296b0`
**5. [Rule 3] Phase 9 contract test accepted only 200**
- **Fix:** `TestPhase09ContractInventory` accepts 200 or 201 (create is now documented as 201, matching the handler) and requires 401 only for login and refresh among public routes (`/lang` is public and never 401).
- **Commits:** `fe04dbc`, `c87148a`
**6. [Process] TDD RED evidence**
- RED runs were recorded and verified `RED_EVIDENCE_OK` by `gsd-tools check tdd-red-evidence` for:
- TestPhase10AlbumRelations (with TestPhase10CollectionOwnerReadOnly)
- TestPhase10RelationSave (with RelationOptions, ForgedID and Boot in the same run)
- TestPhase10Forms (with LangOverride and SPAKeysResolve)
- The cabana Messages, Toolbar, Bundle, FilterOptions and OpenAPIConformance tests and the fonoteka ControllerCopy and Controllers tests were written with or right after their implementation, so no failing run was captured for them.
- As in 10-01, tests and code were committed together per repository so that every commit stays green (CLAUDE.md), so there are no separate `test(...)` RED commits.
**7. [Acceptance note] `grep -rniE 'golem15|album' cabana/relation_field.go`**
- This prints one line: the framework's own import path `git.golem15.com/golem15/summercms/pact`. No plugin table, key, pivot or domain name appears in the file.
---
**Total deviations:** 5 auto-fixed (4 blocking, 1 correctness), 2 process/acceptance notes.
**Impact on plan:** None on scope. The mounted route table differs from the documented one by one shared pattern.
## Issues Encountered
- The known fonoteka.go `parity` failures (`TestMigrateSeedsCanonicalGenres`, `TestSchemaMatchesPHPSnapshot`) remain, as logged in `deferred-items.md`. Everything else passes `go vet` and `go test` in both repositories, including every fonoteka plugin module.
- `gofmt -l internal/build/registry.go` is still pre-existing (deferred).
## Known Stubs
- `admin/src/app/i18n.ts`: `t()` still returns keys because the SPA does not load the bundle yet. The server now serves it at `GET {prefix}/api/v1/lang`, and Plan 10-03 wires `setBundle` at startup. This was intentional per D-28.
## User Setup Required
None.
## Next Phase Readiness
- Plan 10-03 can render lists, forms, filters and settings entirely from typed schemas. It should use:
- `ListSchema.messages`, `toolbarButtons` and `filters` with `/filters/{scope}/options`
- `FormView.messages` and `redirects`, plus `FormField.multiple` and `readOnly`
- `/fields/{field}/options` for relation inputs
- `RecordEnvelope` (`data` plus `meta.labels`) for show and save
- `GET /lang` for the UI strings
- Plan 10-04 has `RelationSchema.messages` for the editors manager.
- Any new admin route needs an inventory entry and a `TestPhase10OpenAPIConformance` case.
---
*Phase: 10-admin-vue-spa*
*Completed: 2026-09-27*
## Self-Check: PASSED
All created files listed above exist; commits fe04dbc, c87148a and 9f296b0 (summercms.go) and 84dbda1, 2914892, 648bff6 and cb6727f (fonoteka.go) are present.