Rewrites the end-of-day wrap skill so it works for any session topic
(not just the loyalty E2E walkthrough) and is safe to run with other
agents working on the same repo concurrently. Detects the session's
topic, creates a memory file if no matching one exists, only edits a
proposal doc if one already exists for the topic, and pushes via
fetch + pull --rebase + retry-once instead of bare push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dashboard unread-messages count crashed Alpine with
"expected expression, got '}'" because I emitted Jinja {{ _(...) | tojson }}
directly inside x-text="..." — the JSON's double quotes broke out of the
attribute, leaving Alpine to parse a malformed expression.
The messages card has its own nested x-data="{ unreadCount: 0 }" scope,
so the parent component's i18n property isn't reachable. Moved the
singular/plural strings onto window.__accountDashboardI18n (added next
to logoutSuccess/logoutFailed) and referenced them by global path from
x-text — no quoting collision and the nested scope sees window fine.
Other touched templates (login/register/forgot/reset-password) already
use x-data='...' (single-quoted outer attribute) for their |tojson
language-selector args, so this collision only existed in dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same accent-stripped pattern as the FR fix in b463c6bf: the DE
password_reset template was missing every umlaut. Restored throughout
the name, description, subject, body_html, and body_text:
- zurucksetzen → zurücksetzen
- Zurucksetzung → Zurücksetzung
- Passwortzurucksetzung → Passwortzurücksetzung
- Schaltflache → Schaltfläche
- lauft → läuft
- konnen → können
- Grussen → Grüßen
Same deploy step: re-run scripts/seed/seed_email_templates_core.py on
prod to upsert the existing row in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the password_reset email body had unaccented French
("demande" instead of "demandé", "L'equipe" instead of "L'équipe") and
the signature was the generic "L'équipe" without the store name.
FR template was missing accents throughout — fixed all of them:
Envoye→Envoyé, Reinitialiser→Réinitialiser, recu→reçu, reinitialisation
→réinitialisation, creer→créer, demande→demandé, equipe→équipe.
Signature on all 4 locales now includes {{ store_name }} (auto-injected
by EmailService.get_branding), so users see "L'équipe Fashion Hub" /
"The Fashion Hub Team" / "Das Fashion Hub Team" / "D'Fashion Hub Team"
instead of an unbranded "The Team".
The seeder is idempotent (upsert on code+language), so re-running
seed_email_templates_core.py on prod will update the existing rows in
place — no DB wipe needed.
Note: DE template still has missing umlauts (zurucksetzen→zurücksetzen,
Schaltflache→Schaltfläche, lauft→läuft, etc.) — left for a separate
DE/LB quality sweep since the user only reported FR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test 5 (storefront password reset + customer dashboard) surfaced five
issues that all traced back to missing i18n plumbing:
- Forgot-password email arrived in EN regardless of storefront locale —
handler now prefers request.state.language over customer.preferred_language,
and loyalty self-enrollment backfills preferred_language for new + returning
customers so future locale-sensitive flows hit the right language without
being told twice.
- reset-password.html rendered "undefined" icon boxes because $icon magic
wasn't loaded in the standalone page — replaced with inline SVGs matching
the forgot-password.html convention.
- reset-password.html was hardcoded English: added lang attr, full _()
sweep (22 new auth.* keys × 4 locales), language selector, and JS
validation strings exposed via tojson.
- "Continue shopping" CTA renamed to "Back to Home" (auth.back_to_home,
4 locales) on login + forgot + reset — loyalty storefronts have no
catalog to continue to, mirroring the earlier enroll-success rename.
- /account dashboard, profile, addresses were hardcoded English in the
body (menu was FR because base layout uses _()). New customers.storefront
.pages.{dashboard,profile,addresses}.* namespace (~80 keys × 4 locales),
templates updated, Alpine JS strings injected via window.__*I18n.
18 files, 18 changed; arch validation: 126 warnings before = 126 after,
mkdocs --strict clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 in d32c1fd5.
- 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>
Architecture rule that fails CI on any new toLocaleDateString /
toLocaleString / toLocaleTimeString / new Intl.* call that hardcodes
'en-US' instead of using I18n.locale. The whole codebase was cleaned
in the preceding commits (06e59f73, bb4c4004, dd1f9af8) so the rule
ships at error severity from day one.
- Rule definition in .architecture-rules/frontend.yaml under
javascript_rules; exceptions: i18n.js (defines the helper), vendor/.
- _check_hardcoded_locale in scripts/validate/validate_architecture.py
wired into both JS validation sites (full scan + per-file -f mode).
- Suppressible per-line with `// noqa: JS-016` for the rare case where
a specific locale is genuinely required (e.g., a US-only invoice
formatter that must use en-US regardless of UI language).
Validator output: 0 JS-016 hits across the codebase. Negative-tested
with a planted violation — rule fires correctly and clears on
removal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 06e59f73 which swept non-loyalty modules. The earlier
loyalty fix (dd1f9af8) only touched the shared/ factories; persona-
specific JS files in loyalty's admin/, merchant/, store/, and
storefront/ dirs were missed and still hardcoded 'en-US'.
13 occurrences across 8 files now use I18n.locale:
- admin: loyalty-analytics.js, loyalty-merchant-detail.js,
loyalty-programs.js
- merchant: loyalty-analytics.js
- store: loyalty-analytics.js, loyalty-terminal.js
- storefront: loyalty-dashboard.js, loyalty-history.js
After this commit grep -rn "'en-US'" --include=*.js across the whole
repo returns nothing. Clearing the deck so the JS-016 rule can ship
at error severity in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to dd1f9af8 which fixed loyalty. The same hardcoded 'en-US'
in toLocaleDateString / toLocaleString / Intl.NumberFormat appeared in
13 files across catalog, marketplace, orders, tenancy, inventory,
monitoring, cms, the storefront layout, and the shared Utils helper
itself. After this sweep, no non-loyalty JS hardcodes 'en-US' anymore;
all use I18n.locale and respect the user's dashboard language.
The highest-leverage one is static/shared/js/utils.js (Utils.formatDate
/ Utils.formatDateTime / Utils.formatCurrency / Utils.formatNumber) —
those four helpers are called from across the frontends so this one
edit fixes most secondary callsites for free.
Codemod scope was conservative: only replaced 'en-US' when it
appeared as the first argument to toLocale* or new Intl.* calls, to
avoid touching unrelated occurrences (none found, but the guard
matters if more get added).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update.
- Test 4 (cross-store redemption) verified: card #5's transaction
history now spans store_id=4 (FASHIONHUB, all the earnings) and
store_id=5 (FASHIONOUTLET, today's -100 redemption). Cross-location
flow confirmed.
- Bug found + fixed (478c3a9c) on the storefront auth API. Both
POST /api/v1/storefront/auth/forgot-password and .../reset-password
declared bare `email: str` / `reset_token: str, new_password: str`
params, which FastAPI treats as query strings. The frontend sends
JSON body, so the call 422'd with "missing query parameter email".
Added PasswordResetRequest + PasswordResetConfirm Pydantic body
schemas; switched both endpoints to body: <Schema>. Surfaced
trying to test Test 5's customer login flow.
- /loyalty-wrap skill committed (d03b96da) — mechanises the end-of-day
routine. First invokable as /loyalty-wrap tomorrow (skills load at
session start).
Carries Test 5 into next session (now unblocked by the auth fix), plus
a new TODO from the user: transaction categories should be creatable
by merchants and store owners, not admin-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/v1/storefront/auth/forgot-password and .../reset-password were
both declared with bare `email: str` / `reset_token: str, new_password: str`
parameters. FastAPI treats unannotated str params as query parameters, so
the frontend's JSON body was ignored and the endpoint 422'd with
"missing query parameter 'email'". The docstrings on both endpoints
already said "Request Body" — intent was clear, implementation drifted.
Add two new Pydantic body schemas in tenancy/schemas/auth.py:
PasswordResetRequest { email: str } (forgot)
PasswordResetConfirm { reset_token: str, new_password: str } (reset)
Re-export from tenancy/schemas/__init__.py, import in
customers/routes/api/storefront.py, and switch both endpoint signatures
to take `body: <Schema>`. Internal usage reads body.email / body.reset_token
/ body.new_password.
Surfaced during Test 5 when user clicked "forgot password" on the customer
storefront login page to set a password for the first time after a
self-enrollment flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dates rendered in English even when the dashboard language was set to
French (or any other locale). The 5 shared loyalty Alpine factories
hardcoded 'en-US' in every toLocaleDateString / toLocaleString /
Intl.NumberFormat call, ignoring the user's selected language.
- Add `I18n.locale` getter to static/shared/js/i18n.js that returns
the current dashboard language code (en/fr/de/lb). Falls back to
'en' if I18n isn't initialised yet.
- Replace 'en-US' with I18n.locale in 5 loyalty shared factories:
loyalty-cards-list, loyalty-card-detail-view, loyalty-transactions-
list, loyalty-pins-list, loyalty-devices-list.
- Also fix a latent bug in loyalty-transactions-list.formatDateTime
that called toLocaleDateString with hour/minute opts (silently
ignored — same bug previously fixed in loyalty-card-detail-view).
Scoped to loyalty per session decision; other modules with the same
hardcoded 'en-US' pattern (catalog, billing, etc.) are tracked as a
follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The transaction-history table on the card-detail page rendered a
Category column only on the store frontend. Merchant and admin saw
five columns instead of six, even though the merchant report
prompted the audit (rewardflow.lu/merchants/loyalty/cards/6 vs
fashionhub.rewardflow.lu/store/.../cards/6).
Root cause was two layers:
- API: only store's GET /cards/{id}/transactions enriched
tx.category_names from tx.category_ids; merchant's and admin's
endpoints returned raw rows with category_names=null.
- Template: the shared partial's show_category_column flag was set
to true only on the store wrapper.
Backfill the same `category_service.validate_category_for_store`
lookup loop into merchant.py::get_card_transactions and
admin.py::get_merchant_card_transactions, accepting Request to read
request.state.language for localised category names. Add
`{% set show_category_column = true %}` to the merchant and admin
card-detail wrappers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every multi-persona module's templates/{admin,merchant,store}/
and classifies each feature cluster as YES / PARTIAL / NO (legit
exception) / N/A for consolidation. Produces a prioritized 10-item
backlog across 3 waves (~8-9 days of focused work, ~3,100-3,500 LOC
removable).
Headline findings:
- 141 persona templates across 9 modules; loyalty already migrated
with 8 shared partials.
- Wave 1 (low risk, ~1,190 LOC): messaging.messages,
messaging.notifications, billing.billing-history.
- Wave 2 (3-persona my-account is the marquee item, ~1,430 LOC):
tenancy.my-account, tenancy.profile, messaging.email-templates.
- Wave 3 (higher complexity, ~1,820 LOC): tenancy.team,
catalog.store-products lists, customers.customers, tenancy.login
(security-gated).
- Anti-candidates documented inline so contributors don't try to
force-fit them (catalog product forms, marketplace admin vs store,
cms content-page-edit, etc.).
Backend services are uniformly scope-agnostic for every top-10
candidate -- no service/route work required.
Added to mkdocs nav under Proposals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Frontend Error Handling (apiClient)" section to the error
handling guide. Callers can branch on `error.errorCode` and read
`error.details` to localise toasts instead of rendering the raw
English `message`, as already done in the loyalty terminal cooldown
handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanises the closing routine we've been doing manually every
session in the loyalty pre-launch walkthrough: update the persistent
memory file + the go-live readiness proposal with today's progress,
run mkdocs strict + architecture validation, commit and push the doc
changes, then print a concise recap with next-session carry-over.
Skill triggers on phrases like "call it a night", "save memory and
docs", "wrap up", etc. when the session has been on the loyalty
walkthrough. SKILL.md documents exact file paths, section templates,
commit message shape, target remote (gitea), and the edge cases
(TPL-016 noise from another agent's in-flight work, architecture
errors blocking push, mkdocs strict failures).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update. Three things in this session:
- Test 3 (staff stamps/points at terminal) — all 6 sub-steps verified
on prod, including the new cooldown rejection and its localised
toast. Tests 1-3 now done; Tests 4-8 ahead.
- Cooldown bug (93ab072f) + localised toast (aa8ca594) — points-based
programs were silently bypassing program.cooldown_minutes because
points_service.earn_points wrote last_points_at but never read it.
Mirror the stamp check + raise new PointsCooldownException with
error_code POINTS_COOLDOWN. Then localise the terminal toast in
en/fr/de/lb (new cooldown_wait_minutes key) and propagate
error.details through the apiClient so the catch site can render
{minutes}.
- Routing investigation (no fix yet, queued for post-walkthrough) —
user hit a 404 on .../platforms/loyalty/store/fashionhub/dashboard
on the subdomain. Diagnosed 4 distinct bugs from path-based→
subdomain/custom-domain drift (Mount 1 broken, server redirect
store.py:86, JS login.js:155, sidebar URL builder). Ran the full
middleware suite (185 tests pass) — depth on inbound resolution,
zero coverage on outbound URL construction; that's why the bugs
slip through. Scoped a Redirect Trace tool on /admin/platform-debug
+ matching integration tests as the regression net.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When earn-points or add-stamp was rejected by the new cooldown
enforcement, the terminal showed the raw English error message from
the backend in the toast, even on FR / DE / LB locales:
"Transaction failed: Please wait 15 minutes between point-earning..."
Two-part fix:
1. static/shared/js/api-client.js — when raising apiError on non-OK
responses, also propagate the `details` payload from the response
body (alongside the existing errorCode). Without this the catch
sites had no structured access to e.g. cooldown_minutes.
2. loyalty-terminal.js — in the catch around the transaction dispatch,
when error.errorCode is POINTS_COOLDOWN or STAMP_COOLDOWN, render a
new localised key loyalty.store.terminal.cooldown_wait_minutes with
{minutes} interpolated from error.details.cooldown_minutes (with a
fallback to this.program.cooldown_minutes). Toast type switches to
'warning' since the rejection is soft (try again later) rather than
a hard failure. Other errors keep the existing 'transaction_failed'
path so nothing else regresses.
Added the new key in en / fr / de / lb under the existing
loyalty.store.terminal.* namespace (sibling of the existing
cooldown_active label).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architecture rule that warns on any template under
app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html that
exceeds 75 LOC AND does not {% include %} a `*/shared/*` partial.
Catches new persona-specific templates that inline body content rather
than sharing it with sibling personas (the project-wide pain point that
prompted the persona-template-consolidation work).
- Rule definition in .architecture-rules/frontend.yaml at warning
severity. Suppressible per-file with `{# noqa: TPL-016 #}`.
- Check function `_check_persona_template_shared_include` in
scripts/validate/validate_architecture.py, wired at both template
validation sites (full scan + per-file -f mode).
- Loyalty was migrated under this rule and reports clean (5 legit
exceptions carry noqa with reason).
- First run surfaces ~110 warnings across other modules — the
migration backlog. Severity stays at warning until at least one
non-loyalty module is migrated, then escalate to error.
See docs/architecture/persona-template-consolidation.md for the
pattern this rule guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The store frontend was inlining two CRUD bodies that already had shared
equivalents under loyalty/templates/loyalty/shared/. Migrate them to the
established pattern (thin per-persona wrapper + shared body partial).
- store/cards.html: 171 -> 56 LOC. Now sets cards_api_prefix /
cards_base_url / show_store_filter=false and includes
shared/cards-list.html (same partial merchant already uses).
- store/card-detail.html: 205 -> 55 LOC. Includes
shared/card-detail-view.html with new flags show_copy_buttons,
show_category_column, show_pagination so its extras survive.
- shared/card-detail-view.html: gain those three boolean flags plus
reads txLabels/txNotes from the Alpine factory (empty defaults so
admin/merchant callers still get raw values).
- shared/loyalty-card-detail-view.js: factory accepts txLabels, txNotes,
paginate config; exposes pagination state unconditionally so the
partial's pagination macro resolves; fix latent bug where
formatDateTime called toLocaleDateString with ignored hour/minute
opts.
- store/loyalty-cards.js + loyalty-card-detail.js: now thin wrappers
calling the shared factories.
- locales/{en,fr,de,lb}.json: add loyalty.shared.card_detail.col_category
for the new optional column.
- Add `noqa: TPL-016` on the 5 legit-exception loyalty templates
(admin/programs aggregator, admin/merchant-settings, admin/wallet-debug,
store/enroll, store/terminal) ahead of the rule landing in a follow-up
commit. Note the per-file reason inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stamp_service.add_stamp checks card.last_stamp_at + cooldown_minutes
before crediting and raises StampCooldownException if too soon. The
parallel points_service.earn_points writes card.last_points_at but
never reads it for enforcement — so cooldown_minutes was silently
ignored for points-based programs.
Mirror the stamps check in points_service.earn_points: after acquiring
the row lock, compare now vs last_points_at + cooldown_minutes and
raise the new PointsCooldownException if the cashier is inside the
window. Add PointsCooldownException alongside StampCooldownException
in exceptions.py with parity wording / error code POINTS_COOLDOWN.
Surfaced during Test 3 step 3.6 — repeated earn-points calls for the
same card kept crediting the customer with no rate limit even though
the program's cooldown_minutes was set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update. Test 2 (cross-store re-enrollment at FASHIONOUTLET
with the email from Test 1) walked cleanly with all behavioral checks
green:
- exactly 1 loyalty_cards row, no duplicate
- zero new email_logs rows (no duplicate welcome email)
- cross-location locations block lists both stores
- title already-enrolled branch renders correctly
One copy bug surfaced and was fixed in dee2eab2: the title at
enroll-success.html:21 was already x-text-conditional on
already_enrolled, but the subtitle below was a static
{{ _('success.message') }} so two contradicting messages stacked
("You're already a member!" + "You're now a member..."). Made the
subtitle conditional the same way and added a new
already_enrolled_message i18n key in en/fr/de/lb.
Test 2 marked done. Carries forward Test 3 plus the existing follow-ups
(Hetzner doc check, B1-F unit tests, prospecting tasks/__init__ fix,
other-module email audit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When re-enrolling (already a member), the success page showed two
contradicting messages stacked:
Title: "Vous êtes déjà membre !" (correct, conditional)
Subtitle: "Vous êtes maintenant membre..." (wrong — static)
The title was already x-text-conditional based on
enrollContext.already_enrolled, but the subtitle was a server-side
{{ _('success.message') }} so it always rendered the "you're now a
member" copy regardless of branch.
Make the subtitle conditional the same way:
- new i18n key already_enrolled_message in en/fr/de/lb
("Welcome back — your card is ready whenever you are." and
locale-appropriate equivalents)
- expose success_message + already_enrolled_message in i18nStrings
- subtitle becomes x-text="already_enrolled ? msg2 : msg1"
Found during Test 2 round 2 — cross-store re-enrollment at
FASHIONOUTLET with the email from Test 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update. Adds a new section to the go-live readiness doc
covering today's three shipped commits:
- 236fee01 — enrollment-success CTA rename (Continuer mes achats
-> Retour à l'accueil)
- ab3e133a — flatpickr birthday picker so Firefox honors dd/mm/yyyy on
FR. Firefox-specific limitation (Mozilla bug #1344625) — Chrome /
Safari / Edge already respected <html lang="fr"> from earlier fix.
- 5f288502 — admin program form now warns when terms fields are both
empty and disables save until at least one is filled, so a
merchant can't accidentally ship a program with a non-clickable
Conditions Générales link on the storefront.
Marks Test 1 fully done (all 6 originally-reported bugs B1-A..B1-F
plus today's 2 follow-up nits resolved end-to-end on prod). Carries
forward the remaining items from yesterday's queue: Test 2, Hetzner
doc check, unit tests for the B1-F chain, prospecting tasks/__init__
fix, and the other-module email-path audit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The initial codemod only converted url_for('*_static', path='*.js'|'*.css')
patterns and missed 19 raw /static/... references — most importantly the
shared/fonts/inter.css link in all four base.html files, plus a handful
of <script src="/static/modules/..."> tags in marketplace/billing/orders
templates and the storefront login/register/forgot/reset pages.
Result: deploys now flip ?v=<sha> on every JS/CSS asset that reaches the
browser, not just the ones loaded via url_for().
FE-024 rule extended to flag src="/static/...*.(js|css)" patterns too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a merchant saves a loyalty program with both terms_text and
terms_cms_page_slug empty, the storefront enrollment page renders the
"Terms & Conditions" link as a non-clickable <span> (see enroll.html
template branch) — customers can't read what they're agreeing to.
Two changes to the shared program-form to make this impossible to ship
by accident:
1. Yellow warning banner inside the Terms section, visible only when
both fields are empty. Tells the admin what the storefront will
look like and what to fix.
2. Save button is disabled until at least one of the two terms
fields is filled. The button gets a localised :title tooltip
explaining why it's disabled, and disabled:cursor-not-allowed so
the disabled state is obvious on hover.
Added three i18n keys (terms_required_warning, terms_text_hint,
terms_required_tooltip) in en/fr/de/lb, plus a small "either this or
the slug above is required" hint under the textarea so each field is
self-explanatory in isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native <input type="date"> defers display format to the browser's
locale, which most engines pick up from the <html lang> attribute.
Firefox is the exception — it ignores lang and uses the OS locale
instead (Mozilla bug #1344625, still open). So FR users on Firefox
still saw mm/dd/yyyy even after the lang fix from earlier this week.
Swap to flatpickr for both the customer storefront enrollment page
and the staff terminal enrollment page. Configure:
- dateFormat: 'Y-m-d' (what gets sent to the API — ISO, what
Pydantic's date field expects)
- altInput: true (flatpickr creates a separate visible input)
- altFormat: 'd/m/Y' (what the user sees — universal in Europe)
- locale: current_language (FR/DE/LB month + day names)
- maxDate: 'today' (no future birthdays)
Load flatpickr core + the optional locale JS via the existing
{% block extra_head %} / {% block extra_scripts %} hooks. The
loyalty/store/enroll.html template didn't have those blocks before,
added them in the same commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `static_v(request, name, path=...)` Jinja helper that appends
?v=<commit-sha> from app.core.build_info, plus a CachedStaticFiles
subclass that serves Cache-Control: public, max-age=31536000, immutable
in production and no-cache in development. Browsers refetch JS/CSS
automatically on every deploy without the user having to hard-reload.
- New: app/core/static_files.py (CachedStaticFiles)
- Updated: app/templates_config.py (static_v helper)
- Updated: main.py (use CachedStaticFiles for *_static mounts)
- Codemod: 143 url_for('*_static', path='*.js'|'*.css') → static_v(...)
across 123 templates. Images/fonts/JSON locales intentionally
unchanged (out of scope).
- Arch rule: FE-024 (warning) flags raw url_for on JS/CSS to prevent
drift. Note: FE-008 was already taken by the number_stepper rule.
- docs/proposals/static-asset-cache-busting.md marked Done.
Closes plan from docs/proposals/static-asset-cache-busting.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The loyalty storefront is a registration / wallet endpoint, not a
catalog — there's nothing to continue shopping toward. The button
already navigates to {{ base_url }} (the homepage), so the
destination was correct; only the wording was wrong.
Rename the i18n key continue_shopping -> back_to_home in
loyalty/enroll-success.html and all four locale files (en/fr/de/lb):
EN: "Continue Shopping" -> "Back to Home"
FR: "Continuer mes achats" -> "Retour à l'accueil"
DE: "Weiter einkaufen" -> "Zurück zur Startseite"
LB: "Weider akafen" -> "Zréck op d'Haaptsäit"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day 2026-05-17 update to the go-live readiness doc.
Welcome email B1-F is now fully resolved end-to-end (enrollment ->
celery dispatch -> email_logs status=sent -> emails landing). The
issue was a chain of four nested bugs each masking the next — the
doc lists all four with commit hashes (44c42909, 3e650ff8, 2a216101,
5b21908b) plus the three earlier same-session fixes for the SMTP
password eye toggle, the JS error on /admin/loyalty/programs, and
the 422 on ProgramCreate.
Also captured:
- Audit finding: prospecting/tasks/__init__.py has the same bug as
bug #3 (scan_tasks.py exists but not imported).
- Six queued follow-ups for next session: two Test 1 storefront nits
(date format on FR, "Continuer mes achats" CTA), Test 2 cross-
store re-enrollment, Hetzner doc check, a concrete unit-test list
that would have caught each of the four B1-F bugs, the prospecting
__init__.py fix, and a wider audit of every module's email path
to find any other silently-broken @shared_task registration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Celery workers were failing every task that touched the DB with
InvalidRequestError: "expression 'ContentPage' failed to locate a name".
Root cause: the worker process only loads task modules during startup,
not routes or services. Models reached only via SQLAlchemy
string-based relationships (e.g. Platform.relationship("ContentPage"))
were never imported, so the mapper couldn't resolve the name when the
first task tried to open a DB session. The FastAPI process avoids this
because api_router transitively imports the world; the worker doesn't.
Add _preload_all_module_models() in celery_config.py that walks the
module registry and importlib.import_module's each "app.modules.<code>.models"
package. Called at module import time, before any task runs.
Surfaced while finally getting loyalty.send_notification_email registered
on the worker — the task ran, hit the DB to load email settings, and
exploded on the unresolved Platform -> ContentPage relationship.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The notifications task module was never imported by the loyalty.tasks
package __init__, so celery's discovery walk loaded the package but
never executed the @shared_task decorator on send_notification_email.
The task was missing from the worker's [tasks] registry, so every
.delay() call resulted in NotRegistered on the worker side — message
ACKed, task silently dropped, no email_logs row written.
Add the import (and update the module docstring / __all__) so the
task is registered alongside the other loyalty background tasks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a Celery task failed, on_failure passed extra={"args": ..., "kwargs": ...}
to logger.error. Python's logging.makeRecord rejects any extra key that
collides with a built-in LogRecord attribute, and "args" is one (used for
printf formatting). The KeyError raised inside the error handler then
cascaded through Celery's trace.handle_failure, masking the real task
exception entirely.
Rename the keys to task_args / task_kwargs.
Surfaced while debugging why the loyalty welcome email never got sent —
the underlying task was failing, but the on_failure handler crashed before
logging the real cause, leaving nothing in worker logs to investigate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposes a one-liner Jinja helper that appends ?v=<commit-sha> to
static JS/CSS URLs, leveraging the existing build_info pipeline so
users no longer need to hard-reload after every deploy. Documents the
codemod scope (143 callsites), open decisions, and the server-side
Cache-Control: immutable follow-up that makes the version flip pay off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'clipboard' name is not registered in static/shared/js/icons.js;
'clipboard-copy' is. Browser logged "Icon \"clipboard\" not found".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the FastAPI process called send_notification_email.delay() it
crashed with kombu OperationalError "[Errno 111] Connection refused"
because the published task was going to amqp://localhost// instead
of our configured redis broker.
Root cause: tasks decorated with @shared_task bind to whatever
Celery considers the "current default app" at decoration time. Our
celery_app (with redis broker) was never imported during FastAPI
startup, so the loyalty / billing / etc. task modules registered
their @shared_task functions against Celery's built-in default —
which has broker_url=amqp://localhost// and no RabbitMQ deployed.
Two-part fix:
1. app/core/celery_config.py — call celery_app.set_default() so
any @shared_task evaluated after this module loads binds to our
app rather than the built-in default.
2. main.py — import app.core.celery_config near the top, BEFORE
api_router (which transitively imports the task modules). The
side-effect of the import (creating celery_app + set_default())
must run before any @shared_task decorator fires. An # isort:
split below the import stops the import-sorter from alphabetising
it back behind app.api.main and re-introducing the bug.
User-visible effect: loyalty welcome emails (and every other
@shared_task-based notification) now actually queue + send.
Surfaced this while debugging why enrollment on prod produced a
loyalty_card row but no email_logs row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin program-edit form sends null for empty number inputs.
ProgramCreate had minimum_purchase_cents declared as int (default
0, ge=0), which rejected null with 422 — even though the DB column
is NOT NULL with default 0 and "0 means no minimum" is the
documented semantics.
Add a field_validator(mode="before") that coerces None to 0 so
the admin form (and any other client that sends null for an empty
optional number) goes through cleanly. The other tolerant fields
in the schema (stamps_reward_value_cents, points_expiration_days)
are already int | None; ProgramUpdate already accepts null here.
User hit this after a clean-DB reset prevented falling back to a
pre-existing program; the merchant area form happens to send 0
instead of null, masking the bug there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The warning panel was wrapped in x-show, which only toggles
display:none — child :href bindings still evaluate, so the
'selectedMerchant.id' access inside the link threw
'TypeError: can't access property "id", selectedMerchant is null'
on every reactive update before a merchant was picked.
Switch to <template x-if> so the element is removed from the DOM
entirely when the condition is false; child bindings then never
run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI: add Clear and Copy-to-clipboard (TSV) buttons, an in-page Recent
Queries pane (localStorage, capped at 20, de-duped) and a pencil-edit
flow for saved queries with a dedicated SQL field in the modal.
Bind Ctrl/Cmd+S to open the save modal (or edit the active saved query).
Backend: harden validate_query with a multi-statement guard that
respects string literals + comments. Stop swallowing record_query_run
errors silently — log via logger.exception so failures show up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap the SMTP password input in a relative container with an
eye / eye-off toggle button so admins can reveal the value while
typing — helps catch copy-paste artifacts and typos when
re-applying credentials after a reset.
State scoped to the field's own x-data block so it doesn't leak
into the parent Alpine context. autocomplete=new-password
prevents browsers from autofilling stale values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a short "Next session" subsection to the 2026-05-16 update
spelling out the three steps to pick up Test 1 round 2 (SMTP
re-apply post-reset, program sanity check, live log tail + fresh
enrollment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/proposals/loyalty-go-live-readiness.md — record the
2026-05-16 session: 7 bugs found during Test 1 round 1
(TimestampMixin, CardDetailResponse, storefront i18n triple,
Makefile, meta_keywords), 6 fixed and deployed with commit
hashes, B1-F still pending repro on the clean DB.
- docs/deployment/hetzner-server-setup.md — fix section 12
step 8 to call seed_email_templates_core.py +
seed_email_templates_loyalty.py instead of the non-existent
seed_email_templates.py, and add seed_demo.py at the end.
Append a note about post-reset state (.build-info + admin
settings).
- mkdocs.yml — sweep 14 previously-unlinked proposals and 2
module docs (loyalty/production-readiness.md,
prospecting/batch-scanning.md) into the nav so the strict
build no longer warns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration cms_003 dropped meta_keywords from content_pages but the
default-pages seed script still passed it to ContentPage(...), so
make db-reset / fresh seeding bombed on the first platform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
init-prod and db-reset both referenced scripts/seed/seed_email_templates.py
which doesn't exist — the real seeders are seed_email_templates_core.py and
seed_email_templates_loyalty.py. db-reset would fail mid-way and skip the
demo seed entirely.
Replace the missing call with both real scripts in both targets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small storefront i18n improvements found during the FR
pre-launch walkthrough on FASHIONHUB:
- Store description (e.g. "Trendy clothing and accessories") was a
single English string rendering in the footer regardless of locale.
Added a description_translations JSON column on Store with the same
shape used elsewhere (CMS, Platform, Subscription), exposed via
get_translated_description(lang), and updated the footer + meta tag
to use it. Seeded FR/DE/LB/EN for FASHIONHUB and FASHIONOUTLET so
Fashion Group renders correctly out of the box. Other stores still
show the single description field as fallback.
- "Home" was a hardcoded English literal in both desktop and mobile
nav, even though the FR translation already existed at nav.home in
static/locales/fr.json. Now uses _('nav.home').
- <html lang="en"> was hardcoded, which made <input type="date"> show
in mm/dd/yyyy on the FR storefront. Now driven by current_language
so the browser's locale-aware date picker matches the page locale.
Migration tenancy_005 adds the description_translations column;
nullable, no backfill needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared card-detail-view template already renders
card.customer_phone and card.customer_birthday, but CardDetailResponse
was missing both fields, so every consumer (admin, store, merchant)
silently returned them as undefined and the UI showed "-".
Added the two fields to the schema and populated them from
customer.phone / customer.birth_date in all three endpoints. Data was
persisting correctly all along — purely a serialization gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TimestampMixin was setting default=datetime.now(UTC) — calling the
function once at module import time. Every INSERT (and every UPDATE
via onupdate) since the app last restarted got stamped with the same
process-start timestamp, silently breaking created_at / updated_at on
every table that uses the mixin (customers, stores, cards, users,
orders, etc.).
Pass the _utc_now callable instead so SQLAlchemy invokes it per row.
Forward fix only — historical rows on prod since the most recent app
restart all read as the start-time microsecond and will need a
separate decision on whether to backfill.
Found while debugging why a customer + their loyalty card had
microsecond-identical created_at timestamps despite being created
through separate service calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Practical field-by-field intake doc for the first merchants — maps
each question to its DB column so the call → admin UI is 1:1.
Includes a section on marketing-material reuse aimed at franchisees,
with written-authorization clauses for the future marketing module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The web user-journey checklist (Tests 1–8) only covers human-using-loyalty
flows from a browser. The cashier-facing Android tablet built in Phases
A–F goes through a different surface and has its own failure modes that
won't surface in any web test. Adding 6 dedicated Android tests so a
tablet-in-hand verification has the same level of structure as the web
side.
- Test 9: Tablet pairing — QR scan + manual entry fallback, with the
audit (paired-device row appears, last_seen_at populated)
- Test 10: PIN screen — wrong/right PIN, offline-capable bcrypt verify,
locked-PIN rejection
- Test 11: Daily flows — search, scan, enroll, stamp, earn, redeem,
with the acting_terminal_device_id audit column check at the end
- Test 12: Offline queue + sync — airplane mode → queued → re-online →
drain; redeem is hard-disabled offline per spec
- Test 13: Auto-lock + manual lock — 2 min idle, immediate lock button,
the known caveat that AlertDialog pointer events don't bubble
- Test 14: Device revocation — revoke on web → 401 on tablet next call
Updated the go-live readiness snapshot to reference these as Step 6b
(gated on user obtaining a tablet, not on schedule).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>