Commit Graph

1246 Commits

Author SHA1 Message Date
d32c1fd545 fix(loyalty): show category column on card-detail for all 3 personas
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>
2026-05-24 23:02:07 +02:00
58a9e3f740 docs(proposals): cross-module persona-template consolidation audit
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h49m10s
CI / validate (push) Successful in 34s
CI / dependency-scanning (push) Successful in 35s
CI / docs (push) Successful in 56s
CI / deploy (push) Successful in 1m11s
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>
2026-05-24 14:09:39 +02:00
82939c0005 docs(api): document apiClient error.status / errorCode / details surface
All checks were successful
CI / ruff (push) Successful in 16s
CI / pytest (push) Successful in 2h48m39s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 36s
CI / docs (push) Successful in 57s
CI / deploy (push) Successful in 1m53s
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>
2026-05-24 00:32:42 +02:00
d03b96da1c chore(skill): add loyalty-wrap end-of-day skill
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-24 00:01:40 +02:00
78621cb7bb docs(loyalty): record 2026-05-23 Test 3 + cooldown bug + routing investigation
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-23 23:44:23 +02:00
aa8ca59493 fix(loyalty-terminal): localise cooldown toast (was raw English)
Some checks failed
CI / ruff (push) Successful in 17s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-23 23:16:32 +02:00
f9a15deed7 feat(arch-rules): TPL-016 flags large persona templates that skip shared/
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-05-23 23:11:21 +02:00
f82dce30ca docs(architecture): persona template consolidation pattern + proposal
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>
2026-05-23 23:10:29 +02:00
c661c1e394 refactor(loyalty): migrate store cards + card-detail to shared partials
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>
2026-05-23 23:09:29 +02:00
93ab072f55 fix(loyalty): enforce cooldown on earn-points (was silently skipped)
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-05-23 22:28:23 +02:00
4b64233b5f docs(loyalty): record 2026-05-19 Test 2 complete + subtitle fix
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h48m35s
CI / validate (push) Successful in 34s
CI / dependency-scanning (push) Successful in 38s
CI / docs (push) Successful in 1m3s
CI / deploy (push) Successful in 1m52s
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>
2026-05-19 22:27:16 +02:00
dee2eab266 fix(loyalty): conditional subtitle on enrollment success page
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-19 22:15:15 +02:00
f68a30a820 docs(loyalty): record 2026-05-18 Test 1 round 2 cleanup + admin polish
All checks were successful
CI / ruff (push) Successful in 19s
CI / pytest (push) Successful in 2h48m6s
CI / validate (push) Successful in 32s
CI / dependency-scanning (push) Successful in 34s
CI / docs (push) Successful in 53s
CI / deploy (push) Successful in 1m16s
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>
2026-05-18 23:42:16 +02:00
78e098d4da fix(static-assets): also cache-bust raw /static/ refs (fonts CSS, store JS)
All checks were successful
CI / ruff (push) Successful in 15s
CI / pytest (push) Successful in 2h47m7s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 36s
CI / docs (push) Successful in 59s
CI / deploy (push) Successful in 1m53s
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>
2026-05-18 23:37:41 +02:00
5f2885023c fix(loyalty-admin): require at least one terms field on program form
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-18 23:36:14 +02:00
ab3e133af7 fix(loyalty): use flatpickr for birthday so Firefox honors dd/mm/yyyy on FR
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-18 22:55:35 +02:00
54247ca4f0 feat(static-assets): cache-bust JS/CSS via ?v=<commit-sha>, immutable in prod
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h50m43s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Successful in 50s
CI / deploy (push) Successful in 1m15s
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>
2026-05-18 19:35:59 +02:00
236fee015e fix(loyalty): rename enrollment-success CTA from "continue shopping" to "back to home"
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-18 19:25:48 +02:00
7cf2420bba docs(loyalty): record B1-F resolution + 6 follow-ups for next session
All checks were successful
CI / ruff (push) Successful in 19s
CI / pytest (push) Successful in 2h52m5s
CI / validate (push) Successful in 34s
CI / dependency-scanning (push) Successful in 36s
CI / docs (push) Successful in 58s
CI / deploy (push) Successful in 1m52s
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>
2026-05-17 23:08:27 +02:00
5b21908ba4 fix(celery): preload every module's models so mapper resolution succeeds
Some checks failed
CI / ruff (push) Successful in 19s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-17 22:57:32 +02:00
2a21610128 fix(loyalty): register send_notification_email with celery worker
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-17 22:50:44 +02:00
3e650ff863 fix(task-base): on_failure logging crashes on reserved LogRecord keys
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
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>
2026-05-17 22:45:22 +02:00
e680fda8bd docs(proposals): static asset cache-busting plan
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-05-17 22:35:24 +02:00
d85271f6aa fix(dev_tools): use registered 'clipboard-copy' icon in SQL Query Tool
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-17 22:31:50 +02:00
44c4290916 fix(celery): bind @shared_task to our app so async dispatch reaches redis
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-17 22:26:33 +02:00
120532e63f fix(loyalty): ProgramCreate accepts null for minimum_purchase_cents
All checks were successful
CI / ruff (push) Successful in 16s
CI / pytest (push) Successful in 2h53m13s
CI / validate (push) Successful in 35s
CI / dependency-scanning (push) Successful in 35s
CI / docs (push) Successful in 57s
CI / deploy (push) Successful in 1m18s
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>
2026-05-17 15:08:29 +02:00
8d6830fc97 fix(loyalty-admin): existing-program warning crashes when no merchant selected
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-05-17 14:25:34 +02:00
e94b6d07bb feat(dev_tools): enhance SQL Query Tool — clear, copy, history, edit, hardening
All checks were successful
CI / ruff (push) Successful in 15s
CI / pytest (push) Successful in 2h42m26s
CI / validate (push) Successful in 34s
CI / dependency-scanning (push) Successful in 34s
CI / docs (push) Successful in 54s
CI / deploy (push) Successful in 1m43s
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>
2026-05-17 11:24:40 +02:00
64a178f45d feat(admin-settings): show/hide toggle on SMTP password field
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-17 11:23:37 +02:00
1425b48239 docs(loyalty): record session pause + next-session resume sequence
All checks were successful
CI / ruff (push) Successful in 17s
CI / pytest (push) Successful in 2h41m22s
CI / validate (push) Successful in 32s
CI / dependency-scanning (push) Successful in 36s
CI / docs (push) Successful in 58s
CI / deploy (push) Successful in 1m12s
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>
2026-05-16 23:50:23 +02:00
eb9afd3cdd docs: loyalty go-live update + Hetzner reset fix + sweep nav
Some checks failed
CI / ruff (push) Successful in 20s
CI / docs (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / deploy (push) Has been cancelled
- 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>
2026-05-16 23:23:50 +02:00
2b8dc84584 fix(seed): drop meta_keywords reference (column removed in cms_003)
All checks were successful
CI / ruff (push) Successful in 13s
CI / pytest (push) Successful in 2h38m58s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 34s
CI / docs (push) Successful in 51s
CI / deploy (push) Successful in 1m43s
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>
2026-05-16 20:33:22 +02:00
f6e224fb24 fix(makefile): call the real email-template seed scripts
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>
2026-05-16 19:50:13 +02:00
06a44e55e7 feat(storefront): translatable Store description + nav home key + dynamic html lang
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>
2026-05-16 19:46:17 +02:00
caf1361291 fix(loyalty): admin/store/merchant card detail return phone + birthday
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>
2026-05-16 19:45:25 +02:00
bdb613581c fix(timestamps): callable default so each row gets a fresh timestamp
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>
2026-05-16 19:44:35 +02:00
29b2170448 docs(onboarding): merchant intake checklist (EN + FR)
All checks were successful
CI / ruff (push) Successful in 17s
CI / pytest (push) Successful in 2h39m52s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 35s
CI / docs (push) Successful in 52s
CI / deploy (push) Successful in 1m43s
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>
2026-05-12 23:08:35 +02:00
a3fb7029bd docs(loyalty): add Android terminal E2E tests to user-journeys checklist
All checks were successful
CI / ruff (push) Successful in 14s
CI / pytest (push) Successful in 2h25m39s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Successful in 50s
CI / deploy (push) Successful in 1m11s
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>
2026-05-10 22:05:00 +02:00
d3b1670623 docs(loyalty): go-live readiness snapshot — 2026-05-10
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-10 22:00:43 +02:00
f2d1bdcd49 fix(messaging): test email + EmailLog show effective config, not stale env
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-10 21:30:41 +02:00
b65c51c038 test: realign deps + page_context tests with current source
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h27m38s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Successful in 50s
CI / validate (push) Successful in 31s
CI / deploy (push) Successful in 3m44s
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>
2026-05-09 22:58:07 +02:00
fc7dc0ccd5 chore(android-terminal): drop deprecated Window.statusBarColor write
Some checks failed
CI / ruff (push) Successful in 13s
CI / pytest (push) Failing after 2h24m23s
CI / validate (push) Successful in 28s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
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>
2026-05-08 00:22:39 +02:00
c1d367bac2 feat(android-terminal): Phase F — kiosk, immersive, queued-action toast
Some checks failed
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-08 00:20:46 +02:00
ac5f46cff3 feat(android-terminal): Phase E — offline queue + sync
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-07 23:54:42 +02:00
c158d920d2 chore(android-terminal): dev cleartext + readable HTTP error messages
Some checks failed
CI / ruff (push) Successful in 16s
CI / pytest (push) Failing after 2h32m6s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
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>
2026-05-06 23:17:44 +02:00
c1bb225228 fix(loyalty): paired-device JWT bypasses program.require_staff_pin
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>
2026-05-06 23:16:33 +02:00
d3f1c33b37 feat(android-terminal): Phase D.5 — auto-lock idle timer
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-06 21:31:16 +02:00
01a12dcef4 feat(android-terminal): Phase D.4 — enrollment dialog + QR scanner overlay
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>
2026-05-06 21:28:44 +02:00
d345d65fd4 feat(android-terminal): Phase D.3 — recent transactions list
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>
2026-05-06 21:25:10 +02:00
02652ee8c6 feat(android-terminal): Phase D.2 — action sheets (stamp/earn/redeem)
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>
2026-05-06 21:13:31 +02:00