All checks were successful
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>
555 lines
34 KiB
Markdown
555 lines
34 KiB
Markdown
# 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`](../modules/loyalty/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-worker` logs live, check `email_logs` for a row. If still no email, then there's a real bug in the dispatch path — `notification_service.send_enrollment_confirmation` is called from `card_service.enroll_customer:636` and wraps the call in a try/except that only logs warnings (`card_service.py:631-645`), so a silent failure in `_resolve_context` or 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_url` into the `loyalty_enrollment` template, generate QR server-side (Python `qrcode`), update the HTML body in all 4 locales in `scripts/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`](../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.py` at 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:
|
||
|
||
1. Re-apply SMTP overrides under `/admin/settings` (port 587, STARTTLS, `support@wizard.lu`) — the reset wiped them.
|
||
2. Confirm `/admin/loyalty/programs` shows the Fashion Group program (should be seeded by `seed_demo.py`).
|
||
3. Tail `api` and `celery-worker` logs live, then enroll at `https://fashionhub.rewardflow.lu/loyalty/join` with 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)
|
||
|
||
1. **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 honor `lang` for `<input type="date">`
|
||
and we need a JS date-picker swap); "Continuer mes achats" CTA on
|
||
`enroll-success.html:118` is wrong for loyalty-only storefronts with no
|
||
catalog.
|
||
2. **Test 2** — cross-store re-enrollment at FASHIONOUTLET with the email
|
||
from Test 1.
|
||
3. **Hetzner doc check** — verify whether `docs/deployment/hetzner-server-setup.md`
|
||
needs any new step from tonight's fixes. Most likely no (the fixes are
|
||
in-code, not deployment), but worth a glance.
|
||
4. **Unit tests** — none of the four B1-F bugs were caught by the existing
|
||
suite. Add at minimum:
|
||
- Assert `celery_app.conf.broker_url` is `redis://...` after importing
|
||
`main` (catches future `set_default()` ordering regressions).
|
||
- Assert `loyalty.send_notification_email` is in `celery_app.tasks`
|
||
after importing `app.modules.loyalty.tasks` (catches future missing
|
||
imports in task package `__init__.py`).
|
||
- Assert `configure_mappers()` succeeds after importing
|
||
`app.core.celery_config` (catches future missing-models regressions
|
||
in celery).
|
||
- Either assert `task_base.on_failure` doesn't crash on a synthetic
|
||
failure, or standardize an `extra=` sanitiser that strips reserved
|
||
`LogRecord` attribute names.
|
||
5. **Fix `prospecting/tasks/__init__.py`** — add the missing import.
|
||
6. **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_logs` row 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`:
|
||
|
||
1. 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.
|
||
2. Save button disabled until at least one of the two terms fields is
|
||
filled. The button gets a localised `:title` tooltip explaining
|
||
why it's disabled, with `disabled:cursor-not-allowed` so 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__.py` missing 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` — propagate `error.details` (alongside `errorCode`) so callers can render localised toasts.
|
||
- `loyalty-terminal.js:277` — in the transaction-dispatch catch, branch on `errorCode === 'POINTS_COOLDOWN' | 'STAMP_COOLDOWN'` and render `loyalty.store.terminal.cooldown_wait_minutes` with `{minutes}` from `error.details.cooldown_minutes`; toast type switches to `warning` since the rejection is soft.
|
||
- New `cooldown_wait_minutes` key in en/fr/de/lb under `loyalty.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):
|
||
|
||
1. **Mount 1 store-resolution broken on subdomain** — `/store/login` returns "Failed to load store information" even though the route is mounted at `main.py:449-458` and the host should resolve the store via middleware. Workaround: use Mount 2 `/store/{STORE_CODE}/login`.
|
||
2. **Server-side post-login redirect leaks dev prefix** — `app/modules/tenancy/routes/pages/store.py:86` builds `/platforms/{platform_code}/store/{store_code}/dashboard` on a subdomain hit (should pick the `:88` branch). Same pattern as B1-B but for redirects.
|
||
3. **JS post-login redirect uses wrong heuristic** — `app/modules/tenancy/static/store/js/login.js:155-158` treats "platform_code is set" as "we're in path-based mode" and prepends `/platforms/{code}/` always. Should check `window.location.pathname.startsWith('/platforms/')` instead.
|
||
4. **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__.py` missing 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 `478c3a9c` fix, verification pending next session).
|
||
|
||
### Carry over for next session
|
||
|
||
1. **Test 5 — password-reset end-to-end** (new top priority): with the
|
||
`478c3a9c` fix deployed, retry the forgot-password flow → confirm an
|
||
`email_logs` row appears with `template_code='password_reset'`,
|
||
`status='sent'` → click the link in the email → set a password → login
|
||
→ continue from step 5.3 (visit `/account/loyalty` dashboard + history).
|
||
2. **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.
|
||
3. **Routing pass** still queued (after Test 8): fix the 4 routing bugs
|
||
+ Redirect Trace admin tool + integration tests + doc updates.
|
||
4. **Existing follow-ups**: Hetzner doc check, B1-F unit tests,
|
||
prospecting `tasks/__init__.py` missing 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
|
||
`api` container to serve the i18n-swept HTML.
|
||
|
||
### Carry over for next session
|
||
|
||
1. **Recreate the prod `api` container** 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 the `x-text` error end-of-day. Verify the dashboard
|
||
renders cleanly after recreate.
|
||
2. **Continue Test 5** from step 5.1 (login as customer) → 5.2
|
||
(`/account/loyalty` dashboard, expect 168 pts) → 5.3
|
||
(`/account/loyalty/history`, expect cross-store transaction list).
|
||
3. **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
|
||
the `static_v()` / `url_for` helpers). The `FE-024` arch rule was
|
||
supposed to guard this — check whether it's firing on these gaps.
|
||
4. **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.
|
||
5. **Transaction categories permissions audit** (carried from
|
||
2026-05-24).
|
||
6. **Routing pass** (carried — after Test 8).
|
||
7. **Existing backlog** (carried): Hetzner doc check, B1-F unit tests,
|
||
prospecting `tasks/__init__.py` missing 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 `.env` had `EMAIL_PROVIDER=sendgrid` + a SendGrid API key
|
||
- SendGrid free trial (60 days) had expired
|
||
- `SMTP_*` env vars were placeholders pointing at `smtp.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=smtp`
|
||
- `smtp_host=mail1.myservices.hosting`
|
||
- `smtp_port=465` ← problematic
|
||
- `smtp_user=support@wizard.lu` / encrypted password
|
||
- `smtp_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:
|
||
|
||
1. `app/modules/core/routes/api/admin_settings.py::send_test_email` —
|
||
body template hardcoded `app_settings.email_provider` and
|
||
`app_settings.email_from_address`
|
||
2. `app/modules/messaging/services/email_service.py` — the "template
|
||
not found" `EmailLog` branch recorded `settings.email_provider` /
|
||
`settings.email_from_address` instead 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:
|
||
|
||
1. **Tonight or tomorrow — review email copy.** Open
|
||
`/admin/email-templates` and skim the 5 loyalty templates (EN
|
||
locale). `loyalty_enrollment`, `loyalty_welcome_bonus` and
|
||
`loyalty_reward_ready` are the customer-visible ones — adjust
|
||
subject lines + body copy if anything reads off-brand.
|
||
2. **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.
|
||
3. **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.
|
||
4. **In parallel, file Google Wallet production access** —
|
||
[pay.google.com/business/console](https://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_templates` permission 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-attempt` endpoint 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`](../modules/loyalty/production-launch-plan.md)
|
||
- Hetzner runbook: [`deployment/hetzner-server-setup.md`](../deployment/hetzner-server-setup.md)
|
||
- Wallet diagnostics page: `/admin/loyalty/wallet-debug` (super admin only)
|
||
- Recent commits relevant to this session:
|
||
- `f2d1bdcd` fix(messaging): test email + EmailLog show effective config
|