Adds a post-audit section to the persona-template consolidation audit capturing what came out of the in-prod card-detail test on rewardflow.lu vs fashionhub.rewardflow.lu: - Template alignment != data alignment: shared partial guarantees the markup is the same per persona, NOT that the API response is. Loyalty's category column rendered empty on merchant + admin because only the store route enriched category_names. Future migrations should diff API response shapes per persona, not just templates. Fixed ind32c1fd5. - Locale-aware formatters are infrastructure, not per-feature. The hardcoded 'en-US' bug spanned 27 callsites across 20+ files. Now swept (dd1f9af8+06e59f73+bb4c4004) and locked down by the JS-016 architecture rule at error severity (eaf180c6). - Sweep + rule, not just sweep. Each cleanup should land with a matching arch rule so the work doesn't decay. Table of the three rules currently guarding this surface (TPL-016, FE-024, JS-016). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
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.inventoryfiles 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)instatic/shared/js/, 2 thin wrappers inadmin/js/+store/js/.
2. messaging.notifications — S effort, L risk → 450 LOC removed
- Same pattern as #1:
notifications-list.htmlshared + 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.htmlshared + 2 wrappers.- Admin's
settlement_idcolumn gates behindshow_settlement_columnflag.
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.htmlshared + 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.htmlshared + 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.htmlshared + 2 wrappers.- Both use Tiptap. Admin has extra usage-stats cards →
show_usage_stats=trueflag.
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.htmlshared.- Merchant audit-log tab extracts to a separate panel with
show_audit_tab=trueflag.
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.htmlshared.- 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.htmlshared.- 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.htmlshared.- Store has an extra "select tenant" step →
show_tenant_selector=trueflag. - 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→errorfor 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
- Wave order: ship in order (1 → 2 → 3) or pick highest-individual-ROI first regardless of risk?
- Login: do the security review now (and include in Wave 3), or defer indefinitely?
- TPL-016 escalation timing: after Wave 1, Wave 2, or only after the whole backlog clears?
- 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.