# 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 `.shared..*` keys; persona-specific wrapper text (page titles, error messages) stays under `...*`. 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/--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.