feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add admin SQL query tool with saved queries, schema explorer presets,
  and collapsible category sections (dev_tools module)
- Add platform debug tool for admin diagnostics
- Add loyalty settings page with owner-only access control
- Fix loyalty settings owner check (use currentUser instead of window.__userData)
- Replace HTTPException with AuthorizationException in loyalty routes
- Expand loyalty module with PIN service, Apple Wallet, program management
- Improve store login with platform detection and multi-platform support
- Update billing feature gates and subscription services
- Add store platform sync improvements and remove is_primary column
- Add unit tests for loyalty (PIN, points, stamps, program services)
- Update i18n translations across dev_tools locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 20:08:07 +01:00
parent a77a8a3a98
commit 319900623a
77 changed files with 5341 additions and 401 deletions

View File

@@ -177,6 +177,52 @@ Injects: request.state.store = <Store object>
**Why it's needed**: Each store storefront can have custom branding
## Login Platform Resolution
The store login endpoint (`POST /api/v1/store/auth/login`) resolves the platform through a 3-source priority chain. This is necessary because on localhost the API path carries no platform information (unlike production where the domain does).
### Source Priority
```
Source 1: Middleware (request.state.platform)
↓ if null or "main"
Source 2: Request body (platform_code field)
↓ if null
Source 3: Fallback (store's first active platform)
```
### Resolution by URL Pattern
| Environment | Login Page URL | API Request Host | Source 1 | Source 2 | Source 3 |
|-------------|---------------|-----------------|----------|----------|----------|
| **Dev path-based** | `/platforms/loyalty/store/ACME/login` | `localhost:8000` | null (localhost → "main" → skipped) | `"loyalty"` from JS | — |
| **Dev no prefix** | `/store/ACME/login` (after logout) | `localhost:8000` | null | `"loyalty"` from localStorage | — |
| **Dev fresh browser** | `/store/ACME/login` (first visit) | `localhost:8000` | null | null | First active platform for store |
| **Prod domain** | `omsflow.lu/store/ACME/login` | `omsflow.lu` | `"oms"` (domain lookup) | — | — |
| **Prod subdomain** | `acme.omsflow.lu/store/login` | `acme.omsflow.lu` | `"oms"` (root domain lookup) | — | — |
| **Prod custom domain** | `wizatech.shop/store/login` | `wizatech.shop` | `"oms"` (StoreDomain lookup) | — | — |
### Client-Side Platform Persistence
On successful login, `login.js` saves the platform to localStorage:
```
localStorage.setItem('store_platform', response.platform_code)
```
On the login page, the platform_code sent in the body uses this priority:
```
window.STORE_PLATFORM_CODE || localStorage.getItem('store_platform') || null
```
- `window.STORE_PLATFORM_CODE` is set by the server template when the URL contains `/platforms/{code}/`
- `localStorage.store_platform` persists across logout (intentionally not cleared)
- This ensures the logout → login cycle preserves platform context in dev mode
### Diagnostic Tools
- **Backend**: `/admin/platform-debug` — traces the full resolution pipeline for arbitrary host/path combos
- **Frontend**: `Ctrl+Shift+P` on any store page (localhost only) — shows JWT platform, localStorage, window globals, and consistency checks
## Naming Conventions
### Middleware File Organization

View File

@@ -1,7 +1,8 @@
# Store Login: JWT Token Gets Wrong Platform
**Status:** Open — needs design review on fallback strategy
**Status:** Resolved
**Date:** 2026-02-24
**Resolved:** 2026-03-10
## Problem
@@ -17,36 +18,37 @@ When a user logs in to a store via `/platforms/loyalty/store/FASHIONHUB/login`,
6. Store auth endpoint calls `get_current_platform(request)` → gets "main" (id=2) instead of "loyalty" (id=3)
7. Token encodes `platform_id=2`, all subsequent menu/API calls use the wrong platform
The Referer-based platform extraction in the middleware (`middleware/platform_context.py` lines 359-374) only handles `/api/v1/storefront/` paths, not `/api/v1/store/` paths.
## Solution (Implemented)
### Why `is_primary` Is Wrong
The login endpoint uses a 3-source priority chain to resolve the platform:
A store can be subscribed to multiple platforms. The platform should be determined by the login URL context (which platform the user navigated from), not by a database default. Using `is_primary` would always pick the same platform regardless of how the user accessed the store.
| Source | How | When it fires |
|--------|-----|---------------|
| **Source 1: Middleware** | `request.state.platform` from domain/subdomain/custom-domain | **Production always** — domain carries platform context in every request |
| **Source 2: Request body** | `platform_code` field in login JSON body | **Dev mode** — JS sends `window.STORE_PLATFORM_CODE \|\| localStorage.store_platform` |
| **Source 3: Fallback** | `get_first_active_platform_id_for_store()` | **Only** on fresh browser in dev mode (no URL context, no localStorage) |
## Key Constraint
### Files Changed
- **Production:** One domain per platform (e.g., `omsflow.lu` for OMS, `loyaltyflow.lu` for loyalty). Store subdomains: `fashionhub.omsflow.lu`. Premium domains: `fashionhub.lu`.
- **Development:** Path-based: `/platforms/{code}/store/{store_code}/login`
- A store can be on multiple platforms and should show different menus depending on which platform URL the user logged in from.
| File | Change |
|------|--------|
| `app/modules/tenancy/routes/api/store_auth.py` | Added `platform_code` to `StoreLoginResponse` and `/me` response |
| `app/modules/tenancy/schemas/auth.py` | Added `platform_code` to `StoreUserResponse` |
| `app/modules/tenancy/static/store/js/login.js` | Save `platform_code` to localStorage on login; use as fallback in login request |
## Current Workaround
### Why Source 3 Fallback Is Safe
`app/modules/tenancy/routes/api/store_auth.py` currently uses `is_primary` to resolve the platform from the store's `store_platforms` table. This works for single-platform stores but breaks for multi-platform stores.
Source 3 only fires when **both** Source 1 and Source 2 have nothing — meaning:
- Not on a platform domain (localhost without `/platforms/` prefix)
- No `platform_code` in request body (no `STORE_PLATFORM_CODE` on page, no localStorage)
## Files Involved
This only happens on a completely fresh browser session in dev mode. In production, Source 1 always resolves because the domain itself identifies the platform.
| File | Role |
|------|------|
| `middleware/platform_context.py` | Platform detection from URL/domain — doesn't cover `/api/v1/store/` paths |
| `middleware/store_context.py` | Store detection from URL/domain |
| `app/modules/tenancy/routes/api/store_auth.py` | Store login endpoint — creates JWT with platform_id |
| `app/modules/tenancy/static/store/js/login.js` | Frontend login — POSTs to `/api/v1/store/auth/login` |
| `static/shared/js/api-client.js` | API client — base URL is `/api/v1` (no platform prefix) |
| `models/schema/auth.py` | `UserLogin` schema — currently has `store_code` but not `platform_code` |
| `app/modules/core/routes/api/store_menu.py` | Menu API — reads `token_platform_id` from JWT |
### Platform Resolution by URL Pattern
## Open Questions
See [middleware.md](../architecture/middleware.md) § "Login Platform Resolution" for the complete matrix.
- What should the fallback strategy be when platform can't be determined from the login context?
- Should the solution also handle storefront customer login (which has the same issue)?
- Should the Referer-based detection in `platform_context.py` be extended to cover `/api/v1/store/` paths as a complementary fix?
## Diagnostic Tools
- **Backend trace**: `/admin/platform-debug` — simulates the full middleware + login resolution pipeline for any host/path combo
- **JS overlay**: `Ctrl+Shift+P` on any store page (localhost only) — shows `window.STORE_PLATFORM_CODE`, `localStorage.store_platform`, JWT decoded platform, `/auth/me` response, and consistency checks

View File

@@ -0,0 +1,173 @@
# Store Menu: Multi-Platform Module Visibility
**Date:** 2026-03-08
**Status:** Resolved — login platform detection fixed, secondary issues fixed
**Affects:** Store sidebar menu for merchants subscribed to multiple platforms
## Problem Statement
When a merchant subscribes to multiple platforms (e.g., OMS + Loyalty), their stores should see menu items from **all** subscribed platforms. Currently, the store sidebar only shows menu items from the store's **primary platform**, hiding items from other subscribed platforms entirely.
**Example:** Fashion Group S.A. subscribes to both OMS and Loyalty platforms. Their store FASHIONHUB should see loyalty menu items (Terminal, Cards, Statistics) in the sidebar, but doesn't — despite the Loyalty platform having the loyalty module enabled and menu items configured.
## Prior Work: Platform Detection in Store Login
This problem was partially identified in commit `cfce6c0c` (2026-02-24) and documented in [`docs/proposals/store-login-platform-detection.md`](store-login-platform-detection.md).
### What was done then
1. **Identified the root cause:** The middleware-detected platform is unreliable for API paths on localhost (e.g., `/api/v1/store/auth/login` defaults to "main" instead of the store's actual platform).
2. **Applied an interim fix in `store_auth.py`:** Instead of using the middleware-detected platform, the login endpoint now resolves the platform from `StorePlatform.is_primary`:
```python
primary_pid = menu_service.get_store_primary_platform_id(db, store.id)
```
This was explicitly labeled an "interim fix" — it works for single-platform stores but breaks for multi-platform stores.
3. **Added production routing support** (commit `ce5b54f2`, 2026-02-26): `StoreContextMiddleware` now checks `StorePlatform.custom_subdomain` for per-platform subdomain overrides. In production, `acme-rewards.rewardflow.lu` resolves via custom_subdomain → StorePlatform → Store, and the platform is known from the domain.
4. **Documented the open question:** How should the store login determine the correct platform when a store belongs to multiple platforms?
### What was NOT solved
- The store menu endpoint still uses a single `platform_id` from the JWT
- No multi-platform module aggregation for the store sidebar
- The `is_primary` interim fix always picks the same platform regardless of login context
## Root Cause (Full Trace)
### 1. Login bakes ONE platform into the JWT
`app/modules/tenancy/routes/api/store_auth.py` (line ~128):
```python
primary_pid = menu_service.get_store_primary_platform_id(db, store.id)
# Returns the ONE StorePlatform row with is_primary=True (OMS)
token_data = auth_service.create_access_token(
platform_id=platform_id, # Only OMS baked into JWT
)
```
### 2. Menu endpoint reads that single platform from JWT
`app/modules/core/routes/api/store_menu.py` (line ~101):
```python
platform_id = current_user.token_platform_id # OMS from JWT
menu = menu_service.get_menu_for_rendering(
platform_id=platform_id, # Only OMS passed here
# enabled_module_codes is NOT passed (defaults to None)
)
```
### 3. Module enablement checked against single platform
`app/modules/core/services/menu_discovery_service.py` (line ~154):
```python
# Since enabled_module_codes=None, falls into per-platform check:
is_module_enabled = module_service.is_module_enabled(db, OMS_platform_id, "loyalty")
# Returns False — loyalty is enabled on Loyalty platform, not OMS
```
### 4. AdminMenuConfig also queried for single platform
Visibility rows in `AdminMenuConfig` are filtered by `platform_id=OMS`, so Loyalty platform's menu config rows are never consulted.
## How Production Routing Affects This
In production, each platform has its own domain:
- `omsflow.lu` → OMS platform
- `rewardflow.lu` → Loyalty platform
When a store manager goes to `fashionhub.rewardflow.lu/login`:
1. `PlatformContextMiddleware` detects `rewardflow.lu` → Loyalty platform ✓
2. `StoreContextMiddleware` checks `StorePlatform.custom_subdomain="fashionhub"` on Loyalty platform → resolves store ✓
3. Login POST goes to same domain → platform context is Loyalty ✓
4. JWT gets `platform_id=Loyalty` ✓
5. Menu shows only Loyalty items ✓ — but OMS items are now hidden!
**The production routing solves "wrong platform" but introduces "single platform" — the store manager sees different menus depending on which domain they logged in from, but never sees items from both platforms simultaneously.**
## Why the Merchant Portal Works
The merchant menu endpoint aggregates across all subscribed platforms:
```python
# app/modules/core/routes/api/merchant_menu.py
for platform_id in all_subscribed_platform_ids:
all_enabled |= module_service.get_enabled_module_codes(db, platform_id)
menu = get_menu_for_rendering(enabled_module_codes=all_enabled)
```
## Proposed Fix Direction
### Option A: Aggregate across platforms (like merchant menu)
The store menu endpoint gathers enabled modules from ALL platforms the store is linked to:
```python
# store_menu.py
platform_ids = platform_service.get_active_platform_ids_for_store(db, store.id)
all_enabled = set()
for pid in platform_ids:
all_enabled |= module_service.get_enabled_module_codes(db, pid)
menu = get_menu_for_rendering(enabled_module_codes=all_enabled)
```
**Pros:** Simple, mirrors merchant pattern, store manager sees everything
**Cons:** AdminMenuConfig visibility still per-platform (needs aggregation too), no visual distinction between platform sources
### Option B: Platform-grouped store menu (like merchant sidebar)
Show items grouped by platform in the store sidebar, similar to how the merchant sidebar groups items under platform headers.
**Pros:** Clear visual separation, respects per-platform menu config
**Cons:** More complex, may be overkill for store context
### Option C: JWT carries login platform, menu aggregates all
Keep the JWT's `platform_id` for audit/context purposes, but change the menu endpoint to always aggregate across all store platforms.
**Pros:** Login context preserved for other uses, menu shows everything
**Cons:** JWT platform becomes informational only
## Secondary Issues (Fix Regardless of Approach)
### A. Loyalty route `/loyalty/programs` does not exist (causes 500)
The onboarding step in `loyalty_onboarding.py` points to `/store/{store_code}/loyalty/programs` but no handler exists. Available: `/loyalty/terminal`, `/loyalty/cards`, `/loyalty/stats`, `/loyalty/enroll`.
**Fix:** Change `route_template` to `/store/{store_code}/loyalty/terminal`.
### B. Broken ORM query in loyalty onboarding
```python
count = db.query(LoyaltyProgram).filter(...).limit(1).count()
# .limit(1).count() is invalid in SQLAlchemy
```
**Fix:** Replace with `.first() is not None`.
### C. Menu item ID mismatch in loyalty module definition
| System | IDs |
|--------|-----|
| Legacy `menu_items` | `"loyalty"`, `"loyalty-cards"`, `"loyalty-stats"` |
| New `menus` | `"terminal"`, `"cards"`, `"stats"` |
**Fix:** Sync legacy IDs with new IDs, re-initialize AdminMenuConfig.
## Key Files
| File | Role |
|------|------|
| `app/modules/tenancy/routes/api/store_auth.py` | Login — bakes platform_id into JWT |
| `app/modules/core/routes/api/store_menu.py` | Menu endpoint — reads single platform from JWT |
| `app/modules/core/services/menu_discovery_service.py` | Module enablement filtering |
| `app/modules/core/services/menu_service.py` | `get_store_primary_platform_id()`, `get_menu_for_rendering()` |
| `app/modules/core/routes/api/merchant_menu.py` | Working multi-platform pattern (for reference) |
| `app/modules/loyalty/definition.py` | Menu item ID mismatch |
| `app/modules/loyalty/services/loyalty_onboarding.py` | Broken route + ORM query |
| `middleware/store_context.py` | Production subdomain/custom_subdomain detection |
| `middleware/platform_context.py` | Platform detection from domain/URL |
| `docs/proposals/store-login-platform-detection.md` | Prior analysis of this problem |
| `scripts/seed/init_production.py` | Platform/module seeding (no menu config seeding) |