docs(architecture): persona template consolidation pattern + proposal

Document how admin/merchant/store templates share a single shared/ body
partial while keeping their three separate base templates. Covers:

- The wrapper/partial split and why the three base templates must stay
  separate (nav + permissions isolation).
- The scope contract: pass strings + booleans only, no macro objects,
  no `persona` enum.
- The backend mirror: services scope-agnostic, routes inject scope via
  auth deps, same Pydantic shape across personas.
- Legit exceptions and the heuristic for when to keep a template
  standalone (multi-tenant aggregators, persona-unique features).
- Forward reference to the TPL-016 architecture rule.

Adds both docs to mkdocs nav under Architecture and Proposals
sections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 23:10:29 +02:00
parent c661c1e394
commit f82dce30ca
3 changed files with 170 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
# Persona Template Consolidation
How to share Jinja templates and JS Alpine factories across the admin / merchant / store personas without losing per-frontend isolation.
## The Problem
Most management features (CRUD over loyalty programs, products, orders, etc.) need to be reachable from three frontends:
- **admin** — platform staff, cross-merchant view
- **merchant** — merchant owner, cross-store view scoped to their merchant
- **store** — store staff, scoped to one store
The naive approach maintains three separate Jinja templates per feature. Every change has to be made three times. They drift. Bug fixes only land in one. Eventually only one persona has the new column or the new modal.
## The Pattern
Two layers:
### 1. Thin per-persona wrapper (one per frontend, MUST stay separate)
Each persona has its own template that:
- `{% extends %}` the persona base (`admin/base.html` / `merchant/base.html` / `store/base.html`). This gives the right sidebar, header, auth widgets, and CSS scope. **Never collapse this to a single base** — the navigation/permissions framing differs by frontend, and merging risks accidentally rendering admin chrome to a store user.
- Renders the page header, any persona-specific intro alerts, and the script tags.
- Sets a handful of Jinja variables describing scope (e.g. `cards_api_prefix='/merchants/loyalty'`, `cards_base_url='/merchants/loyalty/cards'`, `show_store_filter=true`).
- `{% include %}` a shared body partial.
Target size: **2060 LOC**. If a wrapper grows much past that, something persona-unique is creeping in and you should extract it or accept it as a per-persona feature (see "Legit exceptions" below).
### 2. Shared body partial (one per feature, lives in `<module>/templates/<module>/shared/`)
Holds the actual CRUD body — filters, table, form, detail view. Reads the scope variables the wrapper set. Pure Jinja + Alpine bindings. No knowledge of which persona is rendering it.
Naming convention:
- `*-list.html` — table + filter UI for a collection (e.g., `cards-list.html`)
- `*-form.html` — create/edit form (e.g., `program-form.html`)
- `*-view.html` — read-only detail page (e.g., `card-detail-view.html`)
- `*-stats.html` — analytics panel (e.g., `analytics-stats.html`)
## The Scope Contract
Wrappers pass scope to shared partials via **simple Jinja include variables** — strings and booleans only. No object params, no `persona` enum.
**String variables** typically carry URLs:
| Variable | Example | Purpose |
| --- | --- | --- |
| `<feature>_api_prefix` | `'/store/loyalty'` | API base for AJAX |
| `<feature>_base_url` | `'/merchants/loyalty/cards'` | URL prefix for detail links |
| `cancel_url`, `back_url` | `'/admin/loyalty/programs'` | Where actions navigate back to |
**Boolean variables** toggle persona-specific UI:
| Variable | Convention |
| --- | --- |
| `show_X` | Render an optional column / filter / button. Default off. |
| `allow_Y` | Enable a permission-gated action. Default off. |
The pattern lets the shared partial render the right shape without inspecting `persona`. Adding a new persona is "set new vars and include" — never "add another `{% if persona == ... %}` branch".
Canonical example: `app/modules/loyalty/templates/loyalty/shared/cards-list.html`. The header comment block documents every variable the partial expects. Wrappers (`loyalty/admin/...`, `loyalty/merchant/cards.html`, `loyalty/store/cards.html`) each set the four variables and include.
## The Backend Mirror
The same pattern applies one layer down:
- **Services** (`app/modules/<module>/services/`) accept simple ids and stay scope-agnostic. `program_service.list_programs(db, skip, limit, ...)` — no `persona` argument.
- **Routes** (`app/modules/<module>/routes/api/admin.py`, `.../merchant.py`, `.../store.py`) inject scope via auth dependencies (`get_current_admin_api`, `get_merchant_for_current_user`, `get_current_store_api`) and pre-filter the query before calling the service.
- **Schemas** (`app/modules/<module>/schemas/`) return the same Pydantic response across all three personas. Optional fields are fine for admin-only enrichment (e.g., `merchant_name`).
So a feature spans:
```
app/modules/<m>/services/<f>_service.py # scope-agnostic
app/modules/<m>/routes/api/{admin,merchant,store}.py # scope-injecting
app/modules/<m>/schemas/<f>.py # same shape per persona
app/modules/<m>/templates/<m>/shared/<f>-list.html # shared body
app/modules/<m>/templates/<m>/{admin,merchant,store}/<f>.html # thin wrappers
app/modules/<m>/static/shared/js/<m>-<f>-list.js # shared Alpine factory
app/modules/<m>/static/{admin,merchant,store}/js/<m>-<f>.js # thin factory wrappers
```
## Shared Alpine Factory Pattern
JS mirrors the template split. A shared factory in `static/shared/js/` returns the Alpine component for the shared partial. Per-persona JS files are thin wrappers:
```js
// app/modules/loyalty/static/merchant/js/loyalty-cards.js
function merchantLoyaltyCards() {
return loyaltyCardsList({
apiPrefix: '/merchants/loyalty',
baseUrl: '/merchants/loyalty/cards',
showStoreFilter: true,
currentPage: 'cards',
});
}
```
The factory always exposes any state the shared partial might read, even if a particular caller won't render that bit. For example, `loyaltyCardDetailView` always exposes `txLabels: {}` and `pagination: {...}` — the store wrapper populates them, admin/merchant get the empty defaults and the corresponding template blocks don't render.
## Legit Exceptions (when to keep templates standalone)
The pattern shines for symmetric CRUD. It breaks down when a persona genuinely has a different *shape* of view, not just different *data*. Heuristic: **if pushing the shape into the shared partial would force an `{% if scope == 'admin' %}` branch in every row, it's a different view — keep it standalone.**
Real examples in the loyalty module:
- `loyalty/admin/programs.html` — multi-merchant aggregator table with cross-cutting stats and a create-with-merchant-search modal. Merchant/store views show one program each, not a table of many. Standalone.
- `loyalty/admin/merchant-detail.html` — admin-only tabbed view of one merchant's entire loyalty footprint. No merchant/store equivalent.
- `loyalty/store/terminal.html` — hardware POS terminal UI. Store-only feature.
- `loyalty/store/enroll.html`, storefront templates — customer-facing, different audience entirely.
Document the reason inline so future-you (or a contributor) doesn't try to "fix" it.
## Enforcement: `TPL-016`
The architecture validator's `TPL-016` rule (warning) flags any template under `app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html` that exceeds **75 LOC** without `{% include %}`-ing a `*/shared/*` partial. This catches new persona templates that drift into copy-paste. Suppress with `{# noqa: TPL-016 #}` for the legit exceptions above.
The rule is intentionally a warning, not an error — it surfaces drift without breaking CI on first sight. Escalate to error once at least one non-loyalty module has been migrated and we're confident the pattern generalises.