# Persona Template Consolidation — Cross-Module Audit

**Date:** 2026-05-24
**Status:** Proposed (audit + prioritized backlog)
**Motivation:** Loyalty has the persona-template pattern working (`docs/architecture/persona-template-consolidation.md`, enforced by `TPL-016`), and the rule surfaced ~110 warnings across the rest of the codebase. This audit walks every module's persona templates, classifies each cluster as a real consolidation candidate or a legit exception, and produces a prioritized migration backlog.

The goal is **reducing maintenance overhead and preventing scope/permission drift between personas**, not consolidation for its own sake. Several "duplicates" turn out on inspection to be genuinely different features — those stay separate with `{# noqa: TPL-016 #}`.

---

## Headline numbers

- **141 persona-scoped templates** across 9 multi-persona modules.
- **110 TPL-016 warnings** (templates >75 LOC without a shared partial). 0 in loyalty (already migrated), 47 in tenancy alone.
- **~27 candidate feature clusters** where the same feature appears in 2+ personas.
- **~3,100–3,500 LOC of duplication** can be removed by migrating the top-10 candidates. Roughly 8–10 working days of focused work, spread across 3 waves.
- Backend services are **uniformly scope-agnostic** for every top-10 candidate — no service/route refactors needed before template work.

---

## Per-module summary

| Module | admin | merchant | store | Already shares via `shared/` | TPL-016 hits |
| --- | --- | --- | --- | --- | --- |
| loyalty | 11 | 9 | 8 | **8 shared partials** | **0** |
| tenancy | 27 | 6 | 7 | 0 | 47 |
| messaging | 4 | — | 3 | 0 | 7 |
| cms | 4 | — | 3 | 0 | 7 |
| billing | 4 | 3 | 1 | 0 | 5 |
| catalog | 4 | — | 2 | 0 | 4 |
| orders | 1 | — | 3 | 0 | 4 |
| inventory | 1 | — | 1 | 0 | 2 |
| customers | 1 | — | 2 | 0 | 0 |

Loyalty is the reference for the pattern; the other 8 modules are the migration surface.

---

## Cluster matrix

A **cluster** = the same logical feature appearing in two or more personas. Counted only clusters where the personas render the *same shape* of data, not just files that share a filename.

### Genuine consolidation candidates (YES + PARTIAL)

| Module | Feature | admin LOC | merchant LOC | store LOC | Verdict | Notes |
| --- | --- | --- | --- | --- | --- | --- |
| messaging | messages (conversation list) | 339 | — | 282 | **YES** | Same chat table; only conversation_type filter enum differs. ~80% body shared. |
| messaging | notifications | 365 | — | 233 | **YES** | Same table + status badge; only scope filter differs. ~75% shared. |
| messaging | email-templates | 368 | — | 333 | **YES** | Same Tiptap editor + template list; admin has extra usage-stats cards (flag-gate). ~70% shared. |
| tenancy | my-account | 294 | 253 | 243 | **YES** | Identical layout: personal info form + password change + account metadata; only API prefix differs. ~80% shared, **three-persona win**. |
| tenancy | profile | — | 190 | 206 | **YES** | Two-persona near-duplicate. Same edit form, different scope. ~85% shared. |
| tenancy | team (members + invite) | — | 538 | 303 | **YES** | Same member table + invite modal; merchant has an extra audit-log tab (flag-gate). ~70% shared. |
| catalog | store-products / products (list) | 340 | — | 368 | **YES** | Same table + filters + stat cards. Admin has store selector + marketplace-source link (flags). ~70% shared. |
| billing | billing-history | 207 | 144 | — | **YES** | Same invoice table; admin shows settlement_id column. ~75% shared. |
| customers | customers (list) | 221 | — | 214 | **YES** | Same RFM breakdown + table; admin has cross-merchant scope + segment filter (flags). ~70% shared. |
| billing | subscriptions | 320 | 131 | — | **PARTIAL** | Admin shows tiers + multi-merchant aggregation, merchant shows own. Core list reusable; admin tier-management UI stays separate. ~65% shared. |
| catalog | store-product-detail | 358 | — | 174 | **PARTIAL** | Admin has cross-store override indicators + metadata; store has copy-buttons (inventory sync). Core ~60% reusable. |
| catalog | store-product-create / edit | 470–503 | — | 174 | **PARTIAL** | Admin form has 4-language translation tabs + supplier cost + media picker; store form is single-language. Only ~50% reusable; recommend separate forms. |
| cms | content-pages | 205 | — | 339 | **PARTIAL** | Store version embeds media library tabs in the same template. Core table ~55% shared; media UI stays inline. |
| cms | content-page-edit | 705 | — | 337 | **PARTIAL** | Admin has versioning + audit trail; store is simpler with local drafts. Same rich-text editor (~40% shared). |
| orders | orders (list) | 584 | — | 334 | **PARTIAL** | Admin has settlement metadata + merchant column; store has fulfillment workflow (packing slips, bulk status). Core table ~50% shared. |
| tenancy | login | 215 | 216 | 252 | **PARTIAL** | All three share OAuth flow + form. Store has extra "select tenant" step. ~60% shared, **but security-sensitive — gate behind security review**. |
| inventory | inventory | 603 | — | 374 | **PARTIAL** | Both are heavy dashboards. Admin: cross-store stock + variance reports. Store: per-location stock + transfer queue. ~50% shared core. |
| tenancy | store-detail | 665 | 315 | — | **PARTIAL** | Admin has 4× more sections (domain mgmt, role matrix, audit log). Only ~35% shared. |

### Legit exceptions (NO)

These trip TPL-016 by line count but consolidation would force `{% if scope == admin %}` branches everywhere. Each should carry `{# noqa: TPL-016 #}` with a one-line reason.

| File | LOC | Why standalone |
| --- | --- | --- |
| `loyalty/admin/programs.html` | 347 | Multi-merchant aggregator; merchant/store show one program each. Different shape. *(already suppressed)* |
| `loyalty/admin/merchant-detail.html` | 432 | Admin-only tabbed view of an entire merchant's loyalty footprint. *(already suppressed)* |
| `loyalty/admin/merchant-settings.html` | 182 | Admin-only config aggregator. *(already suppressed)* |
| `loyalty/admin/wallet-debug.html` | 905 | Apple/Google wallet diagnostic tool. *(already suppressed)* |
| `loyalty/store/terminal.html` | 411 | POS hardware UI. *(already suppressed)* |
| `loyalty/store/enroll.html` | 175 | Counter-staff enrollment flow. *(already suppressed)* |
| `cms/admin/store-theme.html` | 463 | Admin theme editor — different feature from `cms/store/media.html` (asset library), same LOC is coincidence. |
| `tenancy/admin/merchants.html` + `merchant-create.html` + `merchant-detail.html` + `merchant-edit.html` | 261–494 | Admin-only merchant CRUD. No merchant/store persona equivalents. |
| `tenancy/admin/admin-users.html` + detail/edit | 287–357 | Platform-staff management — fundamentally an admin-only feature. |
| `marketplace/store/marketplace.html` | ~150 | CSV import workflow — different shape from `marketplace/admin/marketplace-products.html` (catalog browser). Not a duplicate, different feature. |
| `orders/store/order-detail.html` | 455 | Store-only fulfillment workflow view. No admin/merchant pair. |
| `orders/store/invoices.html` | 505 | Store-only invoice list + PDF generation. |

### Single-persona (N/A)

Counted for completeness; not consolidation work.

- `customers/store/customer-detail.html` (178) — store-only purchase history view.
- `billing/merchant/subscription-detail.html` (234) — merchant-only.
- `inventory` files where only admin or only store has a version.
- All `tenancy/admin/admin-*` files (admin user management).
- All `tenancy/admin/merchant-*` files (admin merchant CRUD).

---

## Prioritized backlog (top 10)

Ranked by `(LOC × duplication % × edit frequency) / effort × risk`. Each item gives a concrete recipe; backend is ready for all of them.

### Wave 1 — establish the pattern with quick wins (~2 days)

**1. messaging.messages** — `S effort, L risk` → 480 LOC removed
- `messaging/templates/messaging/admin/messages.html` (339) + `store/messages.html` (282) → `shared/messages-list.html` + 2 thin wrappers.
- Only the conversation_type filter enum differs. Currently TPL-016 flagged.
- JS: factory `messagesList(config)` in `static/shared/js/`, 2 thin wrappers in `admin/js/` + `store/js/`.

**2. messaging.notifications** — `S effort, L risk` → 450 LOC removed
- Same pattern as #1: `notifications-list.html` shared + 2 wrappers. Same factory shape.

**3. billing.billing-history** — `S effort, L risk` → 260 LOC removed
- `billing/admin/billing-history.html` (207) + `merchant/billing-history.html` (144) → `invoices-list.html` shared + 2 wrappers.
- Admin's `settlement_id` column gates behind `show_settlement_column` flag.

**Total Wave 1: ~1,190 LOC removed, low risk, proves the pattern outside loyalty.**

### Wave 2 — three-persona wins and form sharing (~3 days)

**4. tenancy.my-account** — `M effort, M risk` → 630 LOC removed
- `tenancy/admin/my-account.html` (294) + `merchant/my-account.html` (253) + `store/my-account.html` (243) → `account-profile.html` shared + 3 wrappers.
- **First three-persona shared template outside loyalty**, highest single-file payoff.
- Watch: token-claim handling differs slightly per persona; verify the password-change endpoint works on each.

**5. tenancy.profile** — `S effort, L risk` → ~310 LOC removed
- `merchant/profile.html` (190) + `store/profile.html` (206) → `profile-form.html` shared + 2 wrappers.
- Cluster with #4 — they're sibling features in the same module; finish together so the `shared/` dir is one consistent set.

**6. messaging.email-templates** — `M effort, M risk` → 490 LOC removed
- `messaging/admin/email-templates.html` (368) + `store/email-templates.html` (333) → `email-template-editor.html` shared + 2 wrappers.
- Both use Tiptap. Admin has extra usage-stats cards → `show_usage_stats=true` flag.

**Total Wave 2: ~1,430 LOC removed, includes the first 3-persona win.**

### Wave 3 — higher complexity (~3 days)

**7. tenancy.team** — `M effort, M risk` → 590 LOC removed
- `merchant/team.html` (538) + `store/team.html` (303) → `team-list.html` + `invite-modal.html` shared.
- Merchant audit-log tab extracts to a separate panel with `show_audit_tab=true` flag.

**8. catalog.store-products (lists only)** — `M effort, M risk` → ~495 LOC removed
- `catalog/admin/store-products.html` (340) + `store/products.html` (368) → `products-list.html` shared.
- Admin's store selector + marketplace-source link gate behind flags.
- **Forms stay separate** (different translation tab system, different field sets — see "Anti-candidates" below).

**9. customers.customers** — `S effort, L risk` → 305 LOC removed
- `customers/admin/customers.html` (221) + `store/customers.html` (214) → `customer-list.html` shared.
- Admin: cross-merchant + segment filter (flag). Store: per-store scope.

**10. tenancy.login** — `M effort, H risk` → ~410 LOC removed (**security review gate**)
- `tenancy/admin/login.html` (215) + `merchant/login.html` (216) + `store/login.html` (252) → `login-form.html` shared.
- Store has an extra "select tenant" step → `show_tenant_selector=true` flag.
- **Do this last** and only after a security review of the shared template; login templates handle session boundaries and CSRF, and a regression here is a auth incident, not a UX bug.

**Total Wave 3: ~1,800 LOC removed (or ~1,390 if login is deferred).**

---

## Anti-candidates (don't consolidate these)

These look like duplicates but should stay separate. The cost of forcing them into a shared partial is higher than the duplication cost.

| Cluster | Reason |
| --- | --- |
| **catalog product forms** (admin 470 LOC vs store 174 LOC) | Admin form has 4-language translation tabs + supplier-cost field + marketplace-source picker. Store form is single-language, simpler. Only ~30% structural overlap; consolidation would mean 7+ flags. Keep separate; `{# noqa: TPL-016 #}` on the admin file. |
| **marketplace admin vs store** | Admin = catalog browser (`marketplace-products.html`). Store = CSV import workflow (`marketplace.html`). Different mental models, not the same feature. |
| **orders detail** (store-only) | Store fulfillment workflow has no admin equivalent. Single-persona feature. |
| **tenancy.store-detail** (admin 665 vs merchant 315) | Admin has 4× more sections (domain mgmt, role matrix, audit log). Only ~35% shared — too much divergence for shared partial. |
| **cms.content-page-edit** | Admin has versioning + audit; store is simpler with local drafts. ~40% shared. Possible later; not a top-10 candidate. |
| **inventory** | Admin (cross-store dashboard) vs store (per-location stock + transfers) are different aggregation levels of the same data. Only ~50% shared. Possible later. |
| **`tenancy/admin/admin-users.html` vs `tenancy/admin/merchants.html`** | These are **two different entities in the same persona**, not cross-persona duplication. Their similarity is "list + filters + table" — that's what shared admin macros (`tables.html`, `pagination.html`) already cover. Consolidating into a feature-specific shared partial would be over-engineering. |

---

## Implementation notes

- **No backend work required** for any Wave 1–2 item. All checked services follow the scope-agnostic + auth-injected-scope pattern already.
- **i18n keys**: each migration follows loyalty's pattern — shared partials use `<module>.shared.<feature>.*` keys; persona-specific wrapper text (page titles, error messages) stays under `<module>.<persona>.<feature>.*`. Add missing keys to all 4 locales (en/fr/de/lb) when migrating.
- **JS factories**: every shared partial gets a paired Alpine factory in `static/shared/js/<module>-<feature>-list.js`. Persona JS files become 10-20 LOC wrappers calling the factory with config.
- **TPL-016 escalation**: once any non-loyalty module fully migrates, escalate the rule from `warning` → `error` for new persona templates over the threshold. This prevents the pattern from being re-broken.

## Timeline estimate

| Wave | Items | Effort | Risk | Cumulative LOC removed |
| --- | --- | --- | --- | --- |
| 1 | messages, notifications, billing-history | ~2 days | Low | ~1,190 |
| 2 | my-account, profile, email-templates | ~3 days | Medium | ~2,620 |
| 3 (no login) | team, catalog products list, customers | ~3 days | Medium | ~4,010 |
| 3 (with login) | + login | +1 day | High (security) | ~4,420 |

**~8–9 days of focused work** to clear the headline backlog. Doesn't include the anti-candidates or the inventory/cms remaining items (those can stay flagged with reasons).

---

## Open questions before starting

1. **Wave order**: ship in order (1 → 2 → 3) or pick highest-individual-ROI first regardless of risk?
2. **Login**: do the security review now (and include in Wave 3), or defer indefinitely?
3. **TPL-016 escalation timing**: after Wave 1, Wave 2, or only after the whole backlog clears?
4. **Per-migration commit cadence**: one PR per cluster (10 PRs), one PR per wave (3 PRs), or one big PR (1)? Loyalty's was a single PR which worked; for cross-module this is riskier.

---

## Out of scope

- Backend (services / routes / schemas) — already scope-agnostic; no work needed.
- Per-frontend base templates (`admin/base.html` / `merchant/base.html` / `store/base.html`) — these correctly stay separate.
- Shared macros under `app/templates/shared/macros/` — those are already shared infrastructure.
- The 5 already-suppressed loyalty exceptions — already documented inline.
- Storefront / customer-facing templates — different audience, not in scope.

---

## Lessons learned from the loyalty migration (post-audit notes)

These came out of in-prod testing on `rewardflow.lu/merchants/loyalty/cards/{id}` vs `/store/.../loyalty/cards/{id}` after the loyalty consolidation shipped. They sharpen the migration recipe for the rest of the backlog.

### 1. Template alignment ≠ data alignment

The shared partial guarantees the **markup** is the same across personas. It doesn't guarantee the **API response** is. Loyalty's `card-detail-view.html` had a `show_category_column` flag, but the column rendered empty on merchant + admin because only the **store** route enriched `tx.category_names` from `category_ids` via `category_service.validate_category_for_store`. Merchant + admin returned raw rows.

**Recipe for every migration**: after wiring up the shared partial, hit each persona's endpoint in a browser (or `curl` to the JSON) and **diff the response shapes**. Any optional/enriched field used by the shared template must be populated by every persona's route, or the shared template must gracefully render `-` for missing data. Fixed in commit `d32c1fd5`.

### 2. Locale-aware formatters are infrastructure, not per-feature

The same bug — hardcoded `'en-US'` in `toLocaleDateString` / `Intl.NumberFormat` — turned up in **27 places** across **20+ JS files** (5 loyalty shared factories, 8 loyalty persona files, 13 non-loyalty files + the shared `Utils` helper). All of them were silently rendering dates and numbers in English even when the dashboard language was French.

The fix landed as three swept commits + a new architecture rule:

| Commit | Scope |
| --- | --- |
| `dd1f9af8` | 5 loyalty shared factories + new `I18n.locale` getter on `static/shared/js/i18n.js` |
| `06e59f73` | 13 non-loyalty files (catalog, marketplace, orders, tenancy, inventory, monitoring, cms, storefront layout, shared Utils) |
| `bb4c4004` | 8 remaining loyalty persona files (admin/merchant/store/storefront) |
| `eaf180c6` | New `JS-016` architecture rule at **error severity** — CI rejects any future hardcoded locale tag |

**Recipe**: don't write `'en-US'` ever; use `I18n.locale`. The rule will reject the PR otherwise. Suppressible per-line with `// noqa: JS-016` for the genuine US-only formatter case.

### 3. Sweep + rule, not just sweep

Sweeping the codebase clean is necessary but not sufficient — without a rule, the next contributor reintroduces the pattern. Every consolidation-style cleanup in this audit should land with a matching architecture rule (warning or error) so the work doesn't decay. Current rules guarding this surface area:

| Rule | Severity | What it blocks |
| --- | --- | --- |
| `TPL-016` | warning | Persona templates >75 LOC that don't include a `*/shared/*` partial |
| `FE-024` | warning | Raw `url_for()` on JS/CSS instead of `static_v()` |
| `JS-016` | **error** | Hardcoded `'en-US'` in `toLocale*` / `Intl.*` calls |

When a Wave 1-3 migration lands, consider whether it deserves a new rule (e.g., "messaging shared factories must accept a `config` arg") — small, focused rules that prevent regression are cheap and high-value.
