Five-issue triage shipped as four commits today: storefront i18n sweep (10a99f98), FR password_reset accents + store-name signature (b463c6bf), DE password_reset umlauts (36fd3781), Alpine x-text quoting fix (1bade6e6), plus a seed-script sys.path fix (213a6053) hit during the prod reseed. Test 5.0 (forgot-password end-to-end on FR) verified end-of-day; Test 5 proper (login + dashboard + history) blocks on recreating the prod api container tomorrow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
34 KiB
Loyalty Go-Live Readiness — 2026-05-10
Snapshot of where the loyalty platform stands the night of 2026-05-10. The
canonical sequenced plan is still
app/modules/loyalty/docs/production-launch-plan.md;
this doc records the current state (✅ / ⏳ / 🟡) and what surfaced
during the prod readiness pass.
TL;DR
The technical pre-launch checklist is green. The remaining gate is a human one — walking the 8 user-journey E2E tests on prod with a real test customer and confirming nothing surprises us. After that, flip the loyalty platform live for FASHIONHUB's stores and start the Google Wallet production-access review in parallel (1–3 day Google review, non-blocking).
2026-05-16 update — Test 1 round 1: 7 bugs found, 6 fixed, 1 pending
First attempt at the customer-facing journey on FASHIONHUB's fallback
subdomain (fashionhub.rewardflow.lu) surfaced more than expected. A
critical timestamp bug was masquerading as a re-enrollment confusion,
which sent us briefly down the wrong investigation path. The clean-slate
reset described below cleared the bad data so the remaining gates can be
verified on a known-good baseline.
Six bugs fixed and deployed to prod (5 commits):
| Bug | Layer | Fix |
|---|---|---|
TimestampMixin evaluated datetime.now(UTC) once at module import — every row stamped at process-start time |
models/database/base.py |
Pass _utc_now callable as default / onupdate. Critical: affected every created_at / updated_at on every table that uses the mixin since the last app restart. |
| Admin/store/merchant card detail page showed "-" for phone + birthday even when both were captured during enrollment | app/modules/loyalty/schemas/card.py + 3 endpoints |
Added customer_phone + customer_birthday to CardDetailResponse and populated from customer.phone / customer.birth_date. Data was persisting all along — purely a serialization gap. |
Storefront <html lang="en"> hardcoded made <input type="date"> show in mm/dd/yyyy on the FR storefront |
app/templates/storefront/base.html |
Dynamic lang="{{ current_language|default('en') }}" so the browser respects the FR locale. |
Storefront nav "Home" rendered as English literal across all locales despite nav.home existing in every locale file |
app/templates/storefront/base.html |
Use {{ _('nav.home') }} on both desktop and mobile nav. |
Store.description (the per-store tagline) was single-language only — FASHIONHUB's "Trendy clothing and accessories" rendered in EN on the FR storefront footer |
Store model + migration tenancy_005 + template + seed_demo.py |
Added description_translations JSON column with the same shape used by CMS / Platform / Subscription. Added get_translated_description(lang) getter with FR/DE → DEFAULT_LANGUAGE → description fallback. Seeded FR/DE/LB/EN for Fashion Group's two stores so they render correctly out of the box. |
make init-prod and make db-reset referenced scripts/seed/seed_email_templates.py, which doesn't exist (the real seeders are _core.py + _loyalty.py) — db-reset would silently bomb mid-way |
Makefile |
Call both real scripts in both targets. |
scripts/seed/create_default_content_pages.py still passed meta_keywords to ContentPage, but the column was dropped in migration cms_003 — fresh seeding failed on the first platform |
scripts/seed/create_default_content_pages.py |
Drop the meta_keywords kwarg. |
One bug still open:
- B1-F — welcome email not received. The original investigation was confounded by the timestamp bug (customer looked like it was from May 12 when it was actually fresh, making the re-enrollment hypothesis seem plausible). Needs fresh repro on the clean DB: enroll with a new email, tail
api+celery-workerlogs live, checkemail_logsfor a row. If still no email, then there's a real bug in the dispatch path —notification_service.send_enrollment_confirmationis called fromcard_service.enroll_customer:636and wraps the call in a try/except that only logs warnings (card_service.py:631-645), so a silent failure in_resolve_contextor the Celery enqueue would be invisible from the user's perspective.
Two product decisions pending (from the same session, not yet implemented):
- B1-E — QR code in welcome email. Scoped: pass
wallet_save_urlinto theloyalty_enrollmenttemplate, generate QR server-side (Pythonqrcode), update the HTML body in all 4 locales inscripts/seed/seed_email_templates_loyalty.py:294-299, reseed. Blocked on B1-F (no point adding a QR to an email that doesn't send). - C1-C backfill scope. Other stores (WizaTech, BookWorld, LuxWeb, WizaMart etc.) still only have a single-language
description. Fashion Group was seeded; rest can be done by hand via admin UI as merchants come online, or batch-updated later. No code work needed.
Prod data reset
Wiped and reseeded — used the corrected sequence from
deployment/hetzner-server-setup.md
section 12. Two doc gaps found and patched in the same pass:
- Reset procedure called
scripts/seed/seed_email_templates.py(doesn't exist) — now calls both real scripts - Reset procedure was missing
seed_demo.pyat the end of step 8 — now included
After reset, admin credentials are back to the defaults from init_production.py (admin / Ollama@8044, etc.); platform admin SMTP overrides in /admin/settings need to be re-applied (port 587, STARTTLS, support@wizard.lu).
Status board delta
- Step 1 (email templates seeded) — re-seeded post-reset, still ✅
- Step 3 (migrations) — now at
tenancy_005, still ✅ - Step 6 (web user-journey E2E tests) — Test 1 round 2 pending on clean DB; the bugs found in round 1 are no longer blockers
Next session
Session paused 2026-05-16 evening. To resume Test 1 round 2:
- Re-apply SMTP overrides under
/admin/settings(port 587, STARTTLS,support@wizard.lu) — the reset wiped them. - Confirm
/admin/loyalty/programsshows the Fashion Group program (should be seeded byseed_demo.py). - Tail
apiandcelery-workerlogs live, then enroll athttps://fashionhub.rewardflow.lu/loyalty/joinwith a fresh email. The point of the live tail is to catch where B1-F actually dies — at dispatch, at SMTP, or somewhere else.
2026-05-17 update — B1-F resolved (chain of 4 nested bugs)
End-to-end enrollment → Celery dispatch → email_logs status=sent → real
emails arriving in inbox. Verified with the FR locale: enrollment
("Bienvenue chez Fashion Group S.A. Loyalty !") and welcome-bonus
("Vous avez gagné 50 points bonus !") both send within ~4s of submit.
The "no welcome email" symptom hid four layered bugs; each silently masked the next, which is why early diagnostics looked clean:
| # | Bug | Fix |
|---|---|---|
| 1 | @shared_task defaulted to amqp://localhost// because celery_app.set_default() was never called AND the api process never imported celery_config. .delay() raised kombu.OperationalError: Connection refused. |
44c42909 — set_default() + early import in main.py (with # isort: split so ruff doesn't reorder it). |
| 2 | on_failure log handler crashed on reserved LogRecord attribute name args → KeyError masked every real task exception. |
3e650ff8 — rename to task_args / task_kwargs. |
| 3 | loyalty.send_notification_email wasn't in worker's task registry — notifications.py wasn't imported by loyalty/tasks/__init__.py. Worker received the message, couldn't find the task, ACKed silently. |
2a216101 — add the import + __all__ entry. |
| 4 | Celery worker process never imported all models. First DB query failed InvalidRequestError: expression 'ContentPage' failed to locate a name. |
5b21908b — _preload_all_module_models() walks the registry and force-imports each module's models package at celery_config load. |
Three earlier same-session commits also shipped: SMTP password eye toggle
(64a178f4), JS error on /admin/loyalty/programs (8d6830fc), 422 on
ProgramCreate (120532e6).
Audit finding
app/modules/prospecting/tasks/__init__.py has the same shape as bug #3
above — scan_tasks.py exists but isn't imported. Not blocking anything
today (no prospecting Celery dispatch is wired up yet), but should be
fixed alongside the unit-test pass below.
Follow-ups (queued for next session)
- Two Test 1 nits — date format mm/dd/yyyy on FR storefront
enrollment form (verify the
<html lang>deploy actually landed; if it did, the user's browser doesn't honorlangfor<input type="date">and we need a JS date-picker swap); "Continuer mes achats" CTA onenroll-success.html:118is wrong for loyalty-only storefronts with no catalog. - Test 2 — cross-store re-enrollment at FASHIONOUTLET with the email from Test 1.
- Hetzner doc check — verify whether
docs/deployment/hetzner-server-setup.mdneeds any new step from tonight's fixes. Most likely no (the fixes are in-code, not deployment), but worth a glance. - Unit tests — none of the four B1-F bugs were caught by the existing
suite. Add at minimum:
- Assert
celery_app.conf.broker_urlisredis://...after importingmain(catches futureset_default()ordering regressions). - Assert
loyalty.send_notification_emailis incelery_app.tasksafter importingapp.modules.loyalty.tasks(catches future missing imports in task package__init__.py). - Assert
configure_mappers()succeeds after importingapp.core.celery_config(catches future missing-models regressions in celery). - Either assert
task_base.on_failuredoesn't crash on a synthetic failure, or standardize anextra=sanitiser that strips reservedLogRecordattribute names.
- Assert
- Fix
prospecting/tasks/__init__.py— add the missing import. - Audit every other module's email path — are billing's trial-expiration
emails really dispatched via Celery? Messaging's password-reset emails?
If yes, same silent-failure risk exists until a real send hits prod. Add
an integration test that triggers a representative email from every
module and asserts an
email_logsrow appears within N seconds.
2026-05-18 update — Test 1 round 2 cleanup + admin polish
Both follow-up #1 items from yesterday shipped + verified by user. Plus a bonus polish on the admin program form, surfaced when the user spotted a non-clickable "Conditions Générales" link on the storefront and asked why.
Test 1 nits resolved
| Bug | Diagnosis | Fix | Commit |
|---|---|---|---|
Birthday picker shows mm/dd/yyyy on FR even though <html lang="fr"> IS in the page source |
Firefox-specific: it ignores the lang attribute for <input type="date"> (Mozilla bug #1344625, open since 2017). Chrome/Safari/Edge respect it. |
Swap to flatpickr on both loyalty/storefront/enroll.html and loyalty/store/enroll.html. Configured dateFormat: 'Y-m-d' (ISO to API) + altInput: true + altFormat: 'd/m/Y' (dd/mm/yyyy visible) + maxDate: 'today' + locale: '{{ current_language }}' for month/day name translations. Loaded via extra_head + extra_scripts blocks. |
ab3e133a |
| "Continuer mes achats" CTA on enrollment success page makes no sense for a loyalty-only storefront with no catalog | Same destination is fine ({{ base_url }}), only the label needed to change. |
Renamed i18n key continue_shopping → back_to_home in all four locales: EN "Back to Home" / FR "Retour à l'accueil" / DE "Zurück zur Startseite" / LB "Zréck op d'Haaptsäit". |
236fee01 |
Admin program-form polish (bonus)
User noticed the storefront's "Conditions Générales" link wasn't
clickable. Root cause: the program had both terms_text and
terms_cms_page_slug empty. The storefront then renders a plain
<span> instead of an <a> (enroll.html:122-124) — intentional, so
the link doesn't open an empty modal, but easy to accidentally publish
a program in this broken state.
Two changes to loyalty/shared/program-form.html:
- Yellow warning banner inside the Terms section, visible only when both fields are empty. Tells the admin exactly what the storefront will look like and what to fix.
- Save button disabled until at least one of the two terms fields is
filled. The button gets a localised
:titletooltip explaining why it's disabled, withdisabled:cursor-not-allowedso the disabled state is obvious on hover.
Three new i18n keys (terms_required_warning, terms_text_hint,
terms_required_tooltip) added in en/fr/de/lb. Shipped as
5f288502.
Test 1 status
All 6 originally-reported bugs (B1-A through B1-F) plus the 2 follow-up nits are now resolved end-to-end on prod. Test 1 is fully done.
Remaining follow-ups (carry over to next session)
From the 2026-05-17 list, items #2 (Test 2), #3 (Hetzner doc check),
#4 (unit tests for the B1-F chain), #5 (prospecting tasks/__init__.py
missing import), and #6 (other-module email audit) are still queued.
Items #1 (Test 1 nits) are now closed by this session's commits.
2026-05-19 update — Test 2 complete
Cross-store re-enrollment at FASHIONOUTLET (same email as Test 1's
successful +17mayf@gmail.com) walked cleanly. Behavioral checks all
green, one copy bug fixed.
Test 2 verification
| Check | Result |
|---|---|
Exactly 1 loyalty_cards row for customer / merchant — no duplicate |
✅ |
Zero new email_logs rows — no duplicate welcome email |
✅ |
| Cross-location locations block lists both Fashion Hub + Fashion Outlet | ✅ |
| Title renders "Vous êtes déjà membre !" (already conditional from prior fix) | ✅ |
Bug fixed: contradicting subtitle on success page (dee2eab2)
The title at enroll-success.html:21 was already x-text-conditional
on enrollContext.already_enrolled — so it correctly switched between
"Welcome!" and "You're already a member!". But the subtitle just below
at line 24 was a static {{ _('success.message') }}, always rendering
"Vous êtes maintenant membre..." even on the already-enrolled branch.
Two contradicting messages stacked.
Added an already_enrolled_message i18n key in en/fr/de/lb and made the
subtitle conditional the same way as the title:
- EN: "Welcome back — your card is ready whenever you are."
- FR: "Heureux de vous revoir — votre carte est prête à l'emploi."
- DE: "Willkommen zurück — Ihre Karte ist einsatzbereit."
- LB: "Wëllkomm zréck — Är Kaart ass prett wann Dir et sidd."
Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅ and 2 ✅ done. Tests 3–8 still ahead.
Carry over for next session
- Test 3 — Staff stamps/points at the terminal (
/store/FASHIONHUB/loyalty/terminal) - Items #3 (Hetzner doc check), #4 (unit tests for the B1-F chain), #5 (prospecting
tasks/__init__.pymissing import), #6 (other-module email audit) still queued from the 2026-05-17 follow-up list.
2026-05-23 update — Test 3 done + cooldown bug + routing investigation
Test 3 (staff stamps/points at terminal) — all 6 sub-steps verified
Lookup by card-number AND by email both work; phone + birthday show correctly on the card detail (B1-D regression check passed); points earning credits; cooldown rejection fires (after the fix below).
Cooldown bug fixed (93ab072f)
stamp_service.add_stamp properly checks cooldown before crediting. The parallel points_service.earn_points wrote card.last_points_at but never read it — so the program's cooldown_minutes was silently ignored for points-based programs. Mirrored the stamp check in points_service after the row lock; added PointsCooldownException with error_code POINTS_COOLDOWN.
Cooldown toast localised (aa8ca594)
After the cooldown fix shipped, the FR-locale toast still showed the raw English from the backend. Three small changes:
static/shared/js/api-client.js— propagateerror.details(alongsideerrorCode) so callers can render localised toasts.loyalty-terminal.js:277— in the transaction-dispatch catch, branch onerrorCode === 'POINTS_COOLDOWN' | 'STAMP_COOLDOWN'and renderloyalty.store.terminal.cooldown_wait_minuteswith{minutes}fromerror.details.cooldown_minutes; toast type switches towarningsince the rejection is soft.- New
cooldown_wait_minuteskey in en/fr/de/lb underloyalty.store.terminal.*.
Routing investigation — 4 distinct bugs in path/host handling (not yet fixed)
User hit a 404 on https://fashionhub.rewardflow.lu/platforms/loyalty/store/fashionhub/dashboard after login, then noticed several other oddities. Diagnostics found four distinct routing-implementation bugs, all from the same architectural drift (path-based dev → subdomain/custom-domain prod):
- Mount 1 store-resolution broken on subdomain —
/store/loginreturns "Failed to load store information" even though the route is mounted atmain.py:449-458and the host should resolve the store via middleware. Workaround: use Mount 2/store/{STORE_CODE}/login. - Server-side post-login redirect leaks dev prefix —
app/modules/tenancy/routes/pages/store.py:86builds/platforms/{platform_code}/store/{store_code}/dashboardon a subdomain hit (should pick the:88branch). Same pattern as B1-B but for redirects. - JS post-login redirect uses wrong heuristic —
app/modules/tenancy/static/store/js/login.js:155-158treats "platform_code is set" as "we're in path-based mode" and prepends/platforms/{code}/always. Should checkwindow.location.pathname.startsWith('/platforms/')instead. - Sidebar URL builder uses code-bearing form on subdomain — works (Mount 2 also matches) but inconsistent with the canonical platform-debug pattern; adds visible cruft to URLs.
Why didn't tests catch this?
Ran the full middleware suite (185 tests, 29s, all green). Confirmed thorough coverage of inbound resolution (host → platform/store, request.state population). Zero coverage on outbound URL construction — no test asserts post-login Location header, sidebar URLs, or Mount 1 actually serving on subdomain. The bugs exist precisely because nothing red-flags them.
Platform-debug enhancement scoped (not implemented)
User suggested enhancing /admin/platform-debug to test redirects. My scope: add a 5th panel called "Redirect Trace" alongside Platform Trace, Domain Health, Permissions Audit, Tenant Isolation. Auto-runs the 12-row (host × URL-pattern) matrix the page already enumerates, simulates each via httpx.AsyncClient(transport=ASGITransport(app=app)), asserts the redirect Location vs the expected canonical. The same backing endpoint becomes the harness for tests/integration/test_redirect_trace.py so the 4 routing bugs would surface in red.
Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, 3 ✅ done. Tests 4–8 ahead.
Carry over for next session
- Test 4 — Cross-store redemption at FASHIONOUTLET with the card from Tests 1-3
- Routing pass (after Test 8 finishes so we don't churn mid-walkthrough): fix the 4 routing bugs in one focused commit, add the RedirectTrace admin tool + the corresponding integration test, update hetzner doc + user-journeys doc Case 3 to match the canonical platform-debug pattern.
- Existing follow-ups still queued: Hetzner doc check, B1-F unit tests, prospecting
tasks/__init__.pymissing import, other-module email audit.
2026-05-24 update — Test 4 done + storefront auth body-schema fix
Test 4 (cross-store redemption) — verified
Card #5 has its full earning history at FASHIONHUB (store_id=4): welcome
bonus 50 + three points_earned totalling 218 = 268 total earned. Today's
points_redeemed -100 @ store_id=5 (FASHIONOUTLET) succeeded cleanly,
producing the mixed-store transaction history the cross-location flow is
supposed to deliver. Balance = 168 pts.
Storefront forgot/reset password endpoints now accept JSON body (478c3a9c)
Both POST /api/v1/storefront/auth/forgot-password and .../reset-password
were declared with bare email: str / reset_token: str, new_password: str
parameters. FastAPI treats unannotated str params as query parameters, so
the storefront's JSON request body was ignored and the endpoint 422'd
with {"loc":["query","email"],"msg":"Field required"}. The endpoint
docstrings even said "Request Body: email" — intent was clear, the
implementation drifted.
Added two body schemas in app/modules/tenancy/schemas/auth.py
(PasswordResetRequest, PasswordResetConfirm), re-exported via
__init__.py, and switched both endpoint signatures to body: <Schema>.
Surfaced when the user tried to test Test 5 (customer storefront login) and needed to set a password on the customer that self-enrolled with just email + name + birthday.
Skill created: /loyalty-wrap (d03b96da)
Mechanises the end-of-day routine that's been manual every session. Lives
at .claude/skills/loyalty-wrap/SKILL.md. Triggers on phrases like "call
it a night", "save memory and docs", "wrap up", etc. Skills load at session
start, so the first session where the user can actually invoke it as
/loyalty-wrap is the next one after the one that committed it.
Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, 3 ✅, 4 ✅
done. Test 5 in progress (blocked tonight on password-reset flow; now
unblocked by the
478c3a9cfix, verification pending next session).
Carry over for next session
- Test 5 — password-reset end-to-end (new top priority): with the
478c3a9cfix deployed, retry the forgot-password flow → confirm anemail_logsrow appears withtemplate_code='password_reset',status='sent'→ click the link in the email → set a password → login → continue from step 5.3 (visit/account/loyaltydashboard + history). - Transaction categories — permissions audit (new item raised by
user): today only admin can create transaction categories. Merchants
and store owners should be able to. Investigate the existing endpoint
in
app/modules/loyalty/services/category_service.py+app/modules/loyalty/routes/api/admin.py, decide the right scope (merchant-level? store-level?), wire up the merchant + store UIs, add the appropriate RBAC permissions. - Routing pass still queued (after Test 8): fix the 4 routing bugs
- Redirect Trace admin tool + integration tests + doc updates.
- Existing follow-ups: Hetzner doc check, B1-F unit tests,
prospecting
tasks/__init__.pymissing import, other-module email audit.
2026-05-29 update — Test 5.0 storefront i18n sweep + FR/DE email accents
Test 5.0 (forgot-password) surfaced 5 distinct issues, all fixed
The user retried Test 5 with the 478c3a9c JSON-body fix in place. The
forgot-password POST succeeded, but five downstream issues showed up in
one walk-through. Triaged analysis-first per user request before
batching the fix, then shipped as four commits.
| # | Where | Fix |
|---|---|---|
| 1 | API forgot-password handler |
Read request.state.language first; fall back to customer.preferred_language (which is now backfilled at loyalty self-enrollment for both new and returning customers so future emails respect storefront locale). |
| 2A | customers/storefront/reset-password.html icons |
Replaced $icon('x-mark' / 'spinner' / 'check') with inline SVGs (matches forgot-password.html convention; standalone templates don't load icons.js). |
| 2B | Same template — full i18n | Added lang attribute, swapped every hardcoded string for _() (22 new auth.* keys × 4 locales), added language selector, threaded JS validation strings via window.__resetPasswordI18n. |
| 3 | login + forgot + reset CTAs | Renamed auth.continue_shopping → auth.back_to_home (loyalty storefronts have no catalog). 4-locale rename. |
| 5 | /account/dashboard, /profile, /addresses body |
i18n sweep across all three customer-area templates (~80 new customers.storefront.pages.{dashboard,profile,addresses}.* keys × 4 locales). |
Issue 4 (login looked "strangely in FR" after the broken reset page) was NOT a bug — login.html was correctly translated all along; the contrast with the broken reset page just made it look weird.
FR + DE password_reset email body restored to native orthography
The seeded FR template body had every accent stripped (Envoye, recu,
demande, equipe, Reinitialiser, etc.). Same pattern in DE
(zurucksetzen, Schaltflache, lauft, konnen, Grussen).
Both templates now read natively. As a bonus, signatures on all 4 locales
were changed from generic ("L'équipe" / "The Team" / "Das Team" /
"D'Team") to {{ store_name }}-interpolated ("L'équipe Fashion Hub" /
"The Fashion Hub Team" / etc.) using the auto-injected store_name
branding variable from EmailService.get_branding.
The seeder is idempotent (upserts on (code, language)), so re-running
scripts/seed/seed_email_templates_core.py updates existing rows in
place — no DB wipe needed.
Alpine quoting bug surfaced and fixed downstream
The customer-dashboard unread-message line crashed Alpine with
"expected expression, got '}'" because the original sweep emitted
{{ _('...')|tojson }} directly inside x-text="..." — the JSON's
double quotes broke out of the double-quoted HTML attribute. Fix moved
the singular/plural strings onto window.__accountDashboardI18n and
referenced them by global path from x-text. The nested
x-data="{ unreadCount: 0 }" scope can't see the parent component's
i18n property, but window.* is always reachable.
The other auth templates using |tojson (language-selector blocks)
escape it via single-quoted outer attributes (x-data='...'), so the
collision was unique to the new dashboard code.
Seed-script path bug surfaced during the prod reseed
scripts/seed/seed_email_templates_core.py had
Path(__file__).parent.parent which resolves to scripts/, not the
project root, so from app.core.database import get_db failed with
ModuleNotFoundError: No module named 'app'. The loyalty sibling had
parent.parent.parent already (correct). Fixed to match. The canonical
deploy command in docs/deployment/hetzner-server-setup.md:549 sets
PYTHONPATH=/app and would have masked the bug anyway, but defence in
depth is cheap.
Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, 3 ✅, 4 ✅,
5.0 ✅ (forgot-password end-to-end on FR, including email
reception and accent correctness). Test 5 itself (login + dashboard +
history) is the next concrete step, gated on recreating the prod
apicontainer to serve the i18n-swept HTML.
Carry over for next session
- Recreate the prod
apicontainer first thing tomorrow:docker compose --profile full up -d --force-recreate api. The Alpine fix (1bade6e6) is in the image built today but the long-running container is still on the old image, so the dashboard still throws thex-texterror end-of-day. Verify the dashboard renders cleanly after recreate. - Continue Test 5 from step 5.1 (login as customer) → 5.2
(
/account/loyaltydashboard, expect 168 pts) → 5.3 (/account/loyalty/history, expect cross-store transaction list). - Static asset cache-busting gaps (new item raised by user): the
?v=<commit-sha>system from the 2026-05-18 cache-busting work is in place, but some JS/CSS still load without the?v=query param. Audit which files miss it (likely standalone templates that bypass thestatic_v()/url_forhelpers). TheFE-024arch rule was supposed to guard this — check whether it's firing on these gaps. - DE/LB email template quality sweep — other DE templates likely
have the same missing-umlaut pattern as
password_reset(signup_welcome, order_confirmation, team_invite, etc.; ~11 codes × 4 locales). LB has inconsistent accents too. Worth a single pass with a native-speaker review. - Transaction categories permissions audit (carried from 2026-05-24).
- Routing pass (carried — after Test 8).
- Existing backlog (carried): Hetzner doc check, B1-F unit tests,
prospecting
tasks/__init__.pymissing import, other-module email audit.
Status board
| # | Pre-launch step | State | Notes |
|---|---|---|---|
| 1 | Seed loyalty email templates on prod | ✅ | 20 rows (5 templates × 4 locales) all is_active=true |
| 2 | Google Wallet config on Hetzner | ✅ | Wallet config validator green: credentials valid, issuer 3388000000023089598, origin https://rewardflow.lu, default logo reachable |
| 3 | Database migrations | ✅ | All four module heads current incl. loyalty_011 (acting-device audit) on prod |
| 4 | FR/DE/LB translations for analytics i18n keys | 🟡 | 8 keys still EN-only. Cosmetic, doesn't block soft launch |
| 5 | messaging.manage_templates permission for store owners |
🟡 | Only matters if merchants self-edit templates. Admin can edit centrally. Defer |
| 6 | 8 web user-journey E2E tests | ⏳ | The remaining gate — user does this with a real test customer |
| 6b | 6 Android terminal E2E tests | ⏳ | Pairing, PIN, daily flows, offline queue, auto-lock, device revoke — gated on user obtaining a tablet |
| 7 | Google Wallet real-device pass test | ✅ | Already confirmed earlier — cards register, points/redeem visible on personal Google Wallet |
| 8 | Go live | ⏳ | Gated by #6. Cleanup test data + enable platform feature flags for FASHIONHUB |
| 9 | Google Wallet production access | ⏳ | Post-launch, 1–3 day Google review. App-side change is zero; same issuer + service account, passes become public-visible once approved |
What got sorted tonight
SMTP wired to a self-hosted mail server
Started here:
- prod
.envhadEMAIL_PROVIDER=sendgrid+ a SendGrid API key - SendGrid free trial (60 days) had expired
SMTP_*env vars were placeholders pointing atsmtp.example.com
Discovered that /admin/settings lets you store SMTP config in the DB
(table admin_settings, category email) and those values win over
.env. User had already configured:
email_provider=smtpsmtp_host=mail1.myservices.hostingsmtp_port=465← problematicsmtp_user=support@wizard.lu/ encrypted passwordsmtp_use_ssl=true, smtp_use_tls=false
Diagnosis from the prod container:
| Check | Result |
|---|---|
DNS resolves mail1.myservices.hosting |
✅ 185.26.107.245 |
TCP mail1.myservices.hosting:465 |
❌ timed out |
TCP mail1.myservices.hosting:587 |
✅ open |
Either Hetzner blocks 465 outbound for this VPS or the provider firewalls Hetzner's IP range on 465. Either way, port 587 (submission
- STARTTLS) is the modern path and works.
Fix: changed /admin/settings to port 587, SSL off, TLS on. Test
email landed in inbox immediately, sender header Support Wizard <support@wizard.lu> — proving the DB override was being used.
Cosmetic bug found and fixed
The test email's body claimed the configuration that would have been
used if .env were authoritative — i.e. it said Provider: sendgrid
and From: noreply@wizard.lu even though the actual send went via
SMTP from support@wizard.lu. Two places in the code:
app/modules/core/routes/api/admin_settings.py::send_test_email— body template hardcodedapp_settings.email_providerandapp_settings.email_from_addressapp/modules/messaging/services/email_service.py— the "template not found"EmailLogbranch recordedsettings.email_provider/settings.email_from_addressinstead of the effective config
Both now read from get_effective_email_config(db) /
self._platform_config, so the test email page and audit logs reflect
what was actually used.
Commit: f2d1bdcd on master, deployed via Gitea Actions.
What the user does next
In priority order:
- Tonight or tomorrow — review email copy. Open
/admin/email-templatesand skim the 5 loyalty templates (EN locale).loyalty_enrollment,loyalty_welcome_bonusandloyalty_reward_readyare the customer-visible ones — adjust subject lines + body copy if anything reads off-brand. - Walk the 8 web user-journey E2E tests — checklist at the bottom of
app/modules/loyalty/docs/user-journeys.md. Use a personal email as the test customer. 2b. Once a tablet is on hand: walk the 6 Android terminal tests — same doc, "Android Terminal Tests" section (Tests 9–14). Covers pairing (QR + manual), offline PIN bcrypt verify, daily flows (stamp/earn/redeem/enroll), offline queue drain, idle auto-lock, and device revocation cutoff. - Flip live for FASHIONHUB — clean any test data, double-check
Celery (
docker compose ps | grep celery), enable loyalty feature on FASHIONHUB's stores via the admin UI. - In parallel, file Google Wallet production access — pay.google.com/business/console → Wallet API → Manage → Request production access. Use sample pass screenshots from FASHIONHUB. Google reviews the Issuer, not individual merchants — once approved all merchants on the platform are covered.
Open follow-ups (non-blocking)
These can wait but are worth tracking:
- FR/DE/LB translations for the 8 analytics i18n keys
(
store.analytics.revenue_title,store.analytics.cohort_title, etc.). EN shows through; cosmetic only. messaging.manage_templatespermission discovery for merchant_owner role — needed if/when merchants self-edit templates. Admin can edit centrally for v1.- Failed-PIN-attempt reporting from Android tablet → server lockout
counter — tablet bcrypts locally and silently fails; a stolen
tablet's brute-forcer doesn't trip server-side lockout. Add a tiny
POST /pins/{id}/record-failed-attemptendpoint plus a call from the PinViewModel's failure branch. - Splash screen + per-action success animation for the Android tablet — Phase F polish that was intentionally deferred.
Reference
- Canonical plan:
app/modules/loyalty/docs/production-launch-plan.md - Hetzner runbook:
deployment/hetzner-server-setup.md - Wallet diagnostics page:
/admin/loyalty/wallet-debug(super admin only) - Recent commits relevant to this session:
f2d1bdcdfix(messaging): test email + EmailLog show effective config