From cfce6c0ca4f9102bb282734c03a4fbb67373b39f Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 24 Feb 2026 13:52:11 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20loyalty=20module=20end-to-end=20?= =?UTF-8?q?=E2=80=94=20merchant=20route,=20store=20menus,=20sidebar,=20API?= =?UTF-8?q?=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add merchant loyalty overview route and template (was 404) - Fix store loyalty route paths to match menu URLs (/{store_code}/loyalty/...) - Add loyalty rewards card to storefront account dashboard - Fix merchant overview to resolve merchant via get_merchant_for_current_user_page - Fix store login to use store's primary platform for JWT token (interim fix) - Fix apiClient to attach status/errorCode to thrown errors (fixes error.status checks in 12+ JS files — loyalty settings, terminal, email templates, etc.) - Hide "Add Product" sidebar button when catalog module is not enabled - Add proposal doc for proper platform detection in store login flow Co-Authored-By: Claude Opus 4.6 --- .../customers/storefront/dashboard.html | 29 +++++ app/modules/loyalty/routes/pages/merchant.py | 100 +++++++++++++++ app/modules/loyalty/routes/pages/store.py | 44 +++++-- .../templates/loyalty/merchant/overview.html | 116 ++++++++++++++++++ app/modules/tenancy/routes/api/store_auth.py | 25 +++- app/templates/store/partials/sidebar.html | 2 + .../store-login-platform-detection.md | 52 ++++++++ static/shared/js/api-client.js | 10 +- 8 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 app/modules/loyalty/routes/pages/merchant.py create mode 100644 app/modules/loyalty/templates/loyalty/merchant/overview.html create mode 100644 docs/proposals/store-login-platform-detection.md diff --git a/app/modules/customers/templates/customers/storefront/dashboard.html b/app/modules/customers/templates/customers/storefront/dashboard.html index f096e3c6..925f4dee 100644 --- a/app/modules/customers/templates/customers/storefront/dashboard.html +++ b/app/modules/customers/templates/customers/storefront/dashboard.html @@ -66,6 +66,35 @@ + {% if 'loyalty' in enabled_modules %} + + +
+
+ +
+
+

Loyalty Rewards

+

View your points & rewards

+
+
+
+ + +
+
+ {% endif %} + dict: + """Build template context for merchant loyalty pages.""" + return get_context_for_frontend( + FrontendType.MERCHANT, + request, + db, + user=current_user, + **extra_context, + ) + + +# ============================================================================ +# LOYALTY OVERVIEW +# ============================================================================ + + +@router.get("/overview", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_overview( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + merchant: Merchant = Depends(get_merchant_for_current_user_page), + db: Session = Depends(get_db), +): + """ + Render merchant loyalty overview page. + + Shows aggregate loyalty program stats across all merchant stores. + """ + # Get merchant stats server-side + merchant_id = merchant.id + stats = {} + try: + stats = program_service.get_merchant_stats(db, merchant_id) + except Exception: + logger.warning( + f"Failed to load loyalty stats for merchant {merchant_id}", + exc_info=True, + ) + + context = _get_merchant_context( + request, + db, + current_user, + loyalty_stats=stats, + merchant_id=merchant_id, + ) + return templates.TemplateResponse( + "loyalty/merchant/overview.html", + context, + ) diff --git a/app/modules/loyalty/routes/pages/store.py b/app/modules/loyalty/routes/pages/store.py index 12b63306..be280c2e 100644 --- a/app/modules/loyalty/routes/pages/store.py +++ b/app/modules/loyalty/routes/pages/store.py @@ -7,12 +7,15 @@ Store pages for: - Loyalty members management - Program settings - Stats dashboard + +Routes follow the standard store convention: /{store_code}/loyalty/... +so they match the menu URLs in definition.py. """ import logging from fastapi import APIRouter, Depends, Path, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session from app.api.deps import get_current_store_from_cookie_or_header, get_db @@ -27,8 +30,9 @@ logger = logging.getLogger(__name__) router = APIRouter() # Route configuration for module route discovery +# No custom prefix — routes include /loyalty/ in their paths to follow +# the standard store convention: /store/{store_code}/loyalty/... ROUTE_CONFIG = { - "prefix": "/loyalty", "tags": ["store-loyalty"], } @@ -76,13 +80,37 @@ def get_store_context( return context +# ============================================================================ +# LOYALTY ROOT (Redirect to Terminal) +# ============================================================================ + + +@router.get( + "/{store_code}/loyalty", + response_class=RedirectResponse, + include_in_schema=False, +) +async def store_loyalty_root( + store_code: str = Path(..., description="Store code"), + current_user: User = Depends(get_current_store_from_cookie_or_header), +): + """ + Redirect loyalty root to the terminal (primary daily interface). + Menu item "Dashboard" points here. + """ + return RedirectResponse( + url=f"/store/{store_code}/loyalty/terminal", + status_code=302, + ) + + # ============================================================================ # LOYALTY TERMINAL (Primary Daily Interface) # ============================================================================ @router.get( - "/{store_code}/terminal", + "/{store_code}/loyalty/terminal", response_class=HTMLResponse, include_in_schema=False, ) @@ -108,7 +136,7 @@ async def store_loyalty_terminal( @router.get( - "/{store_code}/cards", + "/{store_code}/loyalty/cards", response_class=HTMLResponse, include_in_schema=False, ) @@ -129,7 +157,7 @@ async def store_loyalty_cards( @router.get( - "/{store_code}/cards/{card_id}", + "/{store_code}/loyalty/cards/{card_id}", response_class=HTMLResponse, include_in_schema=False, ) @@ -156,7 +184,7 @@ async def store_loyalty_card_detail( @router.get( - "/{store_code}/settings", + "/{store_code}/loyalty/settings", response_class=HTMLResponse, include_in_schema=False, ) @@ -182,7 +210,7 @@ async def store_loyalty_settings( @router.get( - "/{store_code}/stats", + "/{store_code}/loyalty/stats", response_class=HTMLResponse, include_in_schema=False, ) @@ -208,7 +236,7 @@ async def store_loyalty_stats( @router.get( - "/{store_code}/enroll", + "/{store_code}/loyalty/enroll", response_class=HTMLResponse, include_in_schema=False, ) diff --git a/app/modules/loyalty/templates/loyalty/merchant/overview.html b/app/modules/loyalty/templates/loyalty/merchant/overview.html new file mode 100644 index 00000000..52955381 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/merchant/overview.html @@ -0,0 +1,116 @@ +{# app/modules/loyalty/templates/loyalty/merchant/overview.html #} +{% extends "merchant/base.html" %} + +{% block title %}Loyalty Overview{% endblock %} + +{% block content %} +
+ + +
+

Loyalty Overview

+

Loyalty program statistics across all your stores.

+
+ + + + + + +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/tenancy/routes/api/store_auth.py b/app/modules/tenancy/routes/api/store_auth.py index 7f4a3750..f2f5043c 100644 --- a/app/modules/tenancy/routes/api/store_auth.py +++ b/app/modules/tenancy/routes/api/store_auth.py @@ -111,10 +111,27 @@ def store_login( f"for store {store.store_code} as {store_role}" ) - # Get platform from URL context (middleware-detected) - platform = get_current_platform(request) - platform_id = platform.id if platform else None - platform_code = platform.code if platform else None + # Resolve platform from the store's primary platform link. + # 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 platform). + platform_id = None + platform_code = None + if store: + from app.modules.core.services.menu_service import menu_service + from app.modules.tenancy.services.platform_service import platform_service + + primary_pid = menu_service.get_store_primary_platform_id(db, store.id) + if primary_pid: + plat = platform_service.get_platform_by_id(db, primary_pid) + if plat: + platform_id = plat.id + platform_code = plat.code + + if platform_id is None: + # Fallback to middleware-detected platform + platform = get_current_platform(request) + platform_id = platform.id if platform else None + platform_code = platform.code if platform else None # Create store-scoped access token with store information token_data = auth_service.auth_manager.create_access_token( diff --git a/app/templates/store/partials/sidebar.html b/app/templates/store/partials/sidebar.html index c910af79..edf7a1f1 100644 --- a/app/templates/store/partials/sidebar.html +++ b/app/templates/store/partials/sidebar.html @@ -106,6 +106,7 @@ + {% if 'catalog' in enabled_modules %}
+ {% endif %} {% endmacro %} diff --git a/docs/proposals/store-login-platform-detection.md b/docs/proposals/store-login-platform-detection.md new file mode 100644 index 00000000..0d2ef876 --- /dev/null +++ b/docs/proposals/store-login-platform-detection.md @@ -0,0 +1,52 @@ +# Store Login: JWT Token Gets Wrong Platform + +**Status:** Open — needs design review on fallback strategy +**Date:** 2026-02-24 + +## Problem + +When a user logs in to a store via `/platforms/loyalty/store/FASHIONHUB/login`, the JWT token should encode `platform_id=3` (loyalty). Instead, it gets `platform_id=2` (main). This causes the store sidebar menu to show the wrong modules — e.g., analytics (from "main") appears while loyalty (from "loyalty") is missing. + +### Root Cause Chain + +1. User visits `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login` +2. PlatformContextMiddleware detects `loyalty` and rewrites path to `/store/FASHIONHUB/login` — **correct** +3. Login page renders with loyalty platform context — **correct** +4. JavaScript `login.js` POSTs to `apiClient.post('/store/auth/login', ...)` which resolves to `/api/v1/store/auth/login` — **no platform prefix** +5. PlatformContextMiddleware sees `/api/v1/store/auth/login`, doesn't match `/platforms/*`, defaults to "main" platform +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. + +### Why `is_primary` Is Wrong + +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. + +## Key Constraint + +- **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. + +## Current Workaround + +`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. + +## Files Involved + +| 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 | + +## Open Questions + +- 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? diff --git a/static/shared/js/api-client.js b/static/shared/js/api-client.js index 3966632e..05c8bbbb 100644 --- a/static/shared/js/api-client.js +++ b/static/shared/js/api-client.js @@ -150,7 +150,10 @@ class APIClient { const errorMessage = data.message || data.detail || 'Unauthorized - please login again'; apiLog.error('Throwing authentication error:', errorMessage); - throw new Error(errorMessage); + const authError = new Error(errorMessage); + authError.status = response.status; + authError.errorCode = data.error_code; + throw authError; } // Handle non-OK responses @@ -161,7 +164,10 @@ class APIClient { message: errorMessage, errorCode: data.error_code }); - throw new Error(errorMessage); + const apiError = new Error(errorMessage); + apiError.status = response.status; + apiError.errorCode = data.error_code; + throw apiError; } apiLog.info('Request completed successfully');