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>
Captures where the loyalty pre-launch checklist actually stands after
tonight's prod readiness pass:
- Step 1 (email templates seeded) ✅
- Step 2 (Google Wallet config) ✅ validated via wallet-debug
- Step 3 (migrations) ✅ all module heads incl. loyalty_011 on prod
- Step 7 (Wallet real-device test) ✅
- Steps 4, 5 (FR/DE/LB analytics keys, store-owner template
permission) deferred — cosmetic / non-blocking
- Step 6 (8 user-journey E2E tests) is the remaining human gate
- Step 9 (Google Wallet production access) post-launch
Also records the SMTP path-change diagnosis (own mail server on port
465 blocked outbound from Hetzner; switched to 587 STARTTLS via
/admin/settings DB overrides) and the cosmetic fix shipped in
f2d1bdcd so the test email reports the *effective* config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two display bugs where admin-facing email diagnostics reported the
.env-level config even when database overrides were in effect — so a
prod admin who switched from SendGrid (.env) to SMTP via /admin/settings
would receive a test email whose body still said "Provider: sendgrid"
and "From: noreply@wizard.lu", even though the actual From header used
the DB-overridden address. Confusing, and erodes trust in the page that
exists specifically to verify the setup.
- admin_settings.send_test_email: read get_effective_email_config(db)
and display its provider + from_email in the test email's body. Also
use the effective provider when stamping the audit-log row.
- email_service: in the "template not found" EmailLog branch, record
the effective from_email / from_name / provider from
self._platform_config (already populated correctly by __init__),
instead of falling back to the env-only settings.email_*.
Verified by importing both modules; pure plumbing fix, no schema or
behavior change for callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two stale unit-test groups left over from earlier intentional changes:
1. tests/unit/api/test_deps.py — TestGetCurrentStoreApi
get_current_store_api gained a leading `request: Request` param in
commit 6276e9e3 (terminal-device pairing) so the device-token path
could record last_seen IP. The three tests still passed `(creds, db)`
positionally, so `creds` bound to `request` and the function blew up
with `Session has no attribute 'credentials'`. Pass a mock request
first using the existing `_make_request` helper.
2. tests/unit/utils/test_page_context.py — TestBaseUrlCalculation
d591200d switched the storefront base_url builder to use
`store.subdomain or store.store_code` (lowercase slug) because the
store-context middleware resolves URLs by the lowercase slug; the
uppercase `store_code` is for internal use. Tests still asserted the
old uppercase shape. Update the two URL assertions to the lowercase
subdomain, invert `test_base_url_uses_store_code_not_subdomain` into
`test_base_url_uses_subdomain_not_store_code`, and add a fallback
test that exercises the `or store.store_code` path when subdomain
is None.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Window.setStatusBarColor was deprecated in API 35; the recommended path
is enableEdgeToEdge() (already called in MainActivity) which lets the
theme drive the color. Keep the isAppearanceLightStatusBars line so the
icon tint stays correct on the brief moments immersive mode reveals the
bars on swipe.
Silences the last Kotlin compile warning. Build is clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the Android terminal plan (Phases A → F).
- MainActivity:
* Immersive mode via WindowInsetsControllerCompat — hides status +
navigation bars, swipe-to-reveal with auto-hide
(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE). Re-applied in onResume so
a transient overlay can't leave the bars visible.
* Lock Task Mode — startLockTask() in onResume. The manifest already
declares lockTaskMode="if_whitelisted", so this enters kiosk on a
properly MDM-provisioned tablet and is a silent no-op (caught
SecurityException) on dev / unprovisioned devices.
- TerminalScreen:
* SnackbarHost pinned at the bottom; LaunchedEffect(actionResult)
shows "Queued — will sync when back online" whenever an offline
action gets queued, so the cashier has explicit feedback beyond the
top-bar pending pill.
* 4 new locale strings (en/fr/de/lb) for the snackbar copy.
- Manifest cleanup: dropped redundant tools:replace from the debug
AndroidManifest overlay (networkSecurityConfig isn't set in the main
manifest, so the replace directive was a no-op + emitted a warning).
Skipped from the plan: a custom splash screen (the existing theme
background renders for the cold-start frame; adding the splashscreen
library is polish for a follow-up) and per-action success toasts
(the action sheet closing + balance refresh + recent-feed update are
adequate confirmation).
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stamp / earn / enroll actions performed without connectivity now persist
in the existing pending_transactions Room table and drain via a
SyncWorker as soon as the network constraint is satisfied. Redemption
stays online-only (server needs the authoritative balance — queueing
would let cashiers redeem rewards customers have already spent).
Pieces:
- Hilt-Work plumbing: androidx.hilt:hilt-compiler KSP processor wired,
RewardFlowApp now implements Configuration.Provider with the injected
HiltWorkerFactory, AndroidManifest disables the WorkManager auto-init
via the AndroidX startup tools:node="remove" pattern. Bumped Dagger
to 2.55 so its compiler can read Kotlin 2.1 metadata — 2.51 crashed
with "Unable to read Kotlin metadata due to unsupported metadata
version" once we added @Inject lateinit var on the Application class.
- data/sync/SyncWorker.kt — @HiltWorker CoroutineWorker that drains the
queue FIFO. Per row: HTTP error → permanent markFailed; IOException
→ Result.retry(); success → markSynced + sweep at end of run.
- data/repository/QueueRepository.kt — single entrypoint for "queue
this offline action". queueStamp / queuePointsEarn / queueEnroll
Moshi-serialize the request body and enqueueUniqueWork(KEEP) the
worker under a NetworkType.CONNECTED constraint with 30s exponential
backoff. scheduleSync() is idempotent.
- TerminalViewModel: runAction(block, queueOnNetworkFailure) — on
IOException AND a queue lambda is provided, queue + emit
ActionResult.Queued. HttpException paths still surface the server's
message inline. Stamp / Earn / Enroll go through the queue path;
redeem actions pass null. setStaffPinId records who initiated each
queued row. init schedules a sync so anything left from a previous
session drains on screen entry. pendingSyncCount is combined into
state via Flow<Int> from the DAO; when it drops to 0 we refresh the
recent-transactions feed so the UI shows what just synced.
- TerminalScreen: PendingSyncPill in the top bar (only visible when
count > 0) gives the cashier feedback that a queued action is
waiting + that the queue is draining live.
Cleartext + readable HTTP errors from yesterday remain in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small dev-quality fixes shaken out by manual testing:
- Add a debug-only network_security_config.xml that whitelists 10.0.2.2,
localhost and 127.0.0.1 for cleartext HTTP. Without this, the dev
emulator can't reach the Python dev server because targetSdk 35
forbids cleartext HTTP by default. Lives under app/src/debug/ so it
ships only in debug APKs — release builds keep the platform default
(no cleartext at all).
- TerminalViewModel.runAction now extracts the JSON {message: ...} field
from HttpException response bodies instead of just showing "HTTP 400".
Cashiers (and developers) now see "Staff PIN is required for this
operation" / "Daily stamp limit of N reached" inline in the failed
action sheet, surfacing the same business-error text the server
already returns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the request principal is a paired POS terminal device
(current_user.terminal_device_id is not None), the staff PIN is
considered already-verified — the cashier bcrypt-verified locally on
the tablet's lock screen against the cached hashes from
/pins/for-device. Web-terminal user JWTs still require the per-action
PIN as before; the strict fraud-prevention path is unchanged.
Threat-model note: the device JWT is itself proof of authentication.
The merchant owner paired the device, the cashier verified locally,
and the JWT is revocable from /merchants/loyalty/devices. The 2-min
idle auto-lock + acting_terminal_device_id audit column give us the
attribution we'd otherwise get from a per-action PIN.
Applied to: stamp_service.add_stamp / redeem_stamps / void_stamps;
points_service.earn_points / redeem_points / void_points. adjust_points
was already permissive on missing PIN. New tests in TestDevicePinBypass
lock both the bypass behavior and the still-strict web-terminal path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the terminal screen in an IdleTracker that observes pointer-down
events and fires onLockScreen after 2 min of silence (per spec). Each
tap restarts the timer via a LaunchedEffect re-launch.
awaitFirstDown(requireUnconsumed = false) lets us observe touches
without intercepting them, so children (text fields, buttons, lists,
keypad) keep working normally. Body extracted into a private
TerminalContent so the wrapper stays tidy.
Caveat: Compose AlertDialogs render in a separate window, so the timer
keeps ticking through an open dialog. This is correct behavior — a
cashier who walks away mid-dialog should be locked out — and the
dialog disposes when the NavHost pops the terminal route on lock.
Documented in IdleTracker.kt.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both left-pane buttons now functional. Cashier can find a customer by
scanning their loyalty QR or enroll a brand-new customer from the
tablet — closing the last gap in the everyday POS flow.
- TerminalViewModel: scannerOpen / enrollDialogOpen / enrolling /
enrollError state. submitEnroll posts to /cards/enroll, then re-fetches
the lookup shape so the customer pane renders fully (rewards,
cooldown). On lookup-after-enroll failure (rare) the new card_number
is pre-filled in the search field as a fallback.
- EnrollDialog.kt (new): AlertDialog with name + email (required), phone
+ birthday (optional; birthday is plain YYYY-MM-DD text — date picker
is a polish task). Inline error surface for backend rejections.
- QrScannerOverlay.kt (new): fullscreen overlay reusing QrScannerView
from Phase B. Cancel button top-right. Decoded value is treated as a
card_number and feeds the lookup flow.
- TerminalScreen: scan/enroll buttons are no longer disabled; the two
new composables render conditionally on top of the main layout.
The tablet now supports every everyday flow: lookup, scan, enroll,
stamp, earn points, redeem stamps, redeem reward, recent feed.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Left pane below the search/scan/enroll buttons now shows the last ~10
transactions for the merchant, refreshed on init and after every
successful action sheet submission.
- ApiModels: TransactionListResponse + TransactionItem mirroring the
store-API shape (deltas, customer/staff names, purchase amount,
reward description, ISO transaction_at).
- LoyaltyApi.listRecentTransactions(skip, limit) → GET /transactions.
- TerminalViewModel.refreshRecentTransactions called on init and from
runAction's success path so balances + feed stay in lockstep.
- TerminalScreen: RecentTransactionsList composable. Each row renders
the type (translated by transactionLabel), customer name when known,
signed delta (+50 pts / +1 ★ / -100 pts), and HH:mm timestamp
localized to the device timezone via java.time (Android 26+).
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four right-pane buttons now work end-to-end (online happy path).
Each opens an AlertDialog wired to a ViewModel action; on success the
sheet closes and the customer card refreshes from the server.
- TerminalViewModel: + categories cache load (via CategoryRepository),
+ activeAction / actionInProgress / actionResult on the state. Action
methods (submitStamp, submitEarnPoints, submitRedeemStamps,
submitRedeemReward) all funnel through a runAction helper that toggles
in-progress, calls the LoyaltyApi, refreshes the customer on success,
surfaces failures inline.
- ActionSheets.kt (new): one ActionSheet entrypoint that dispatches by
ActionKind. Category multi-select via FlowRow + FilterChip pills.
EarnPoints accepts "12.50" / "12,50" → cents. Reward picker shows
available_rewards from the lookup response.
- TerminalScreen: action buttons enabled per state — stamp/earn stay
active offline (Phase E will queue them), redeem is hard-disabled when
offline per the plan ("redemption requires an internet connection").
Categories list comes from the cache filled at pairing; fresh refresh
runs in the background so newly-added categories show up next launch
without a manual sync.
Verified by ./gradlew assembleDebug — clean build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>