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>
19 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.
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