diff --git a/docs/architecture/middleware.md b/docs/architecture/middleware.md index f366cba7..67a2e6f1 100644 --- a/docs/architecture/middleware.md +++ b/docs/architecture/middleware.md @@ -93,14 +93,18 @@ INFO Response: 200 for GET /admin/dashboard (0.143s) **What it does**: - Detects store from: - - Custom domain (e.g., `customdomain.com`) - - Subdomain (e.g., `store1.platform.com`) + - Custom domain (e.g., `customdomain.com`) — via `StoreDomain` lookup (optionally scoped to a platform via `StoreDomain.platform_id`) + - Subdomain with two-step lookup: + 1. `StorePlatform.custom_subdomain` — per-platform subdomain overrides (e.g., `wizatech-rewards.rewardflow.lu`) + 2. `Store.subdomain` — standard subdomain fallback (e.g., `wizatech.omsflow.lu`) - Path prefix (e.g., `/store/store1/` or `/stores/store1/`) - Queries database to find store by domain or code -- Injects store object into `request.state.store` +- Injects store object and platform into `request.state.store` - Extracts "clean path" (path without store prefix) - Sets `request.state.clean_path` for routing +**Reserved paths**: When a storefront domain is detected, certain paths are excluded from the `/storefront/` rewrite: `/store/`, `/admin/`, `/api/`, `/static/`, `/storefront/`, `/health`, `/docs`, `/redoc`, `/media/`, `/assets/`, and `/merchants/`. This ensures that staff dashboards, APIs, and merchant-level routes are not incorrectly routed to storefront pages. + **Example**: ``` Request: https://orion.platform.com/storefront/products diff --git a/docs/architecture/request-flow.md b/docs/architecture/request-flow.md index bfe867f5..1d918022 100644 --- a/docs/architecture/request-flow.md +++ b/docs/architecture/request-flow.md @@ -82,27 +82,33 @@ logger.info(f"Request: GET /storefront/products from 192.168.1.100") - Queries database for store - Extracts clean path -**Example Processing** (Subdomain Mode): +**Example Processing** (Subdomain Mode — Two-Step Lookup): ```python # Input -host = "orion.platform.com" +host = "wizatech-rewards.rewardflow.lu" path = "/storefront/products" -# Detection logic -if host != settings.platform_domain: - # Subdomain detected - store_code = host.split('.')[0] # "orion" +# Detection logic — two-step subdomain lookup +subdomain = host.split('.')[0] # "wizatech-rewards" - # Query database +# Step 1: Check per-platform custom subdomain (StorePlatform.custom_subdomain) +store_platform = db.query(StorePlatform).filter( + StorePlatform.custom_subdomain == subdomain +).first() + +if store_platform: + store = store_platform.store +else: + # Step 2: Fall back to standard Store.subdomain store = db.query(Store).filter( - Store.code == store_code + Store.subdomain == subdomain ).first() - # Set request state - request.state.store = store - request.state.store_id = store.id - request.state.clean_path = "/storefront/products" # Already clean +# Set request state +request.state.store = store +request.state.store_id = store.id +request.state.clean_path = "/storefront/products" # Already clean ``` **Request State After**: @@ -116,23 +122,32 @@ request.state.clean_path = "/storefront/products" **What happens**: - FastAPI matches the request path against registered routes -- For path-based development mode, routes are registered with two prefixes: - - `/storefront/*` for subdomain/custom domain - - `/stores/{store_code}/storefront/*` for path-based development +- Both storefront and store dashboard routes are registered with two prefixes each: + - Storefront: `/storefront/*` and `/storefront/{store_code}/*` + - Store dashboard: `/store/*` and `/store/{store_code}/*` **Example** (Path-Based Mode): ```python -# In main.py - Double router mounting +# In main.py - Double router mounting for storefront app.include_router(storefront_pages.router, prefix="/storefront") -app.include_router(storefront_pages.router, prefix="/stores/{store_code}/storefront") +app.include_router(storefront_pages.router, prefix="/storefront/{store_code}") -# Request: /stores/ORION/storefront/products -# Matches: Second router (/stores/{store_code}/storefront) -# Route: @router.get("/products") -# store_code available as path parameter = "ORION" +# In main.py - Double router mounting for store dashboard +app.include_router(store_pages.router, prefix="/store") +app.include_router(store_pages.router, prefix="/store/{store_code}") + +# Storefront request: /storefront/ACME/products +# Matches: Second storefront router (/storefront/{store_code}) +# store_code = "ACME" + +# Store dashboard request: /store/ACME/dashboard +# Matches: Second store router (/store/{store_code}) +# store_code = "ACME" ``` +Route handlers use the `get_resolved_store_code` dependency to transparently resolve the store code from either the path parameter (dev mode) or middleware state (production subdomain/custom domain). + **Note:** Previous implementations used `PathRewriteMiddleware` to rewrite paths. This has been replaced with FastAPI's native routing via double router mounting. **Request State After**: No changes to state, but internal path updated diff --git a/docs/architecture/url-routing/overview.md b/docs/architecture/url-routing/overview.md index b5c1cd04..faa2f9bc 100644 --- a/docs/architecture/url-routing/overview.md +++ b/docs/architecture/url-routing/overview.md @@ -13,11 +13,16 @@ There are three ways depending on the deployment mode: https://STORE_SUBDOMAIN.platform-domain.lu/ https://STORE_SUBDOMAIN.platform-domain.lu/products https://STORE_SUBDOMAIN.platform-domain.lu/cart +https://STORE_SUBDOMAIN.platform-domain.lu/store/dashboard (staff) Example: https://acme.omsflow.lu/ https://acme.omsflow.lu/products https://techpro.rewardflow.lu/account/dashboard +https://acme.omsflow.lu/store/dashboard (staff) + +Per-platform subdomain override: +https://wizatech-rewards.rewardflow.lu/ (same store as wizatech.omsflow.lu) ``` ### 2. **CUSTOM DOMAIN MODE** (Production - Premium) @@ -227,7 +232,9 @@ Request arrives 1. Customer visits `https://acme.omsflow.lu/products` 2. `PlatformContextMiddleware` detects subdomain `"acme"`, resolves platform from root domain `omsflow.lu` 3. Middleware rewrites path: `/products` → `/storefront/products` (internal) -4. `store_context_middleware` detects subdomain, queries: `SELECT * FROM stores WHERE subdomain = 'acme'` +4. `store_context_middleware` performs two-step subdomain lookup: + - First: `SELECT * FROM store_platforms WHERE custom_subdomain = 'acme'` (per-platform override) + - Fallback: `SELECT * FROM stores WHERE subdomain = 'acme'` (standard subdomain) 5. Sets `request.state.store = Store(ACME Store)` 6. `frontend_type_middleware` detects STOREFRONT from `/storefront` path prefix 7. `theme_context_middleware` loads ACME's theme @@ -310,6 +317,7 @@ id | store_id | domain | is_active | is_verified ### Subdomain/Custom Domain (PRODUCTION) ``` +Storefront (customer-facing): https://acme.omsflow.lu/ → Homepage https://acme.omsflow.lu/products → Product Catalog https://acme.omsflow.lu/products/123 → Product Detail @@ -320,7 +328,16 @@ https://acme.omsflow.lu/search?q=laptop → Search Results https://acme.omsflow.lu/account/login → Customer Login https://acme.omsflow.lu/account/dashboard → Account Dashboard (Auth Required) https://acme.omsflow.lu/account/orders → Order History (Auth Required) + +Store Dashboard (staff): https://acme.omsflow.lu/store/dashboard → Staff Dashboard (Auth Required) +https://acme.omsflow.lu/store/products → Manage Products +https://acme.omsflow.lu/store/orders → Manage Orders +https://acme.omsflow.lu/store/login → Staff Login + +Per-platform subdomain override: +https://wizatech-rewards.rewardflow.lu/ → Same store as wizatech.omsflow.lu +https://wizatech-rewards.rewardflow.lu/store/dashboard → Staff dashboard on loyalty platform ``` Note: In production, the root path `/` is the storefront. The `PlatformContextMiddleware` @@ -524,28 +541,46 @@ In Jinja2 template: **Current Solution: Double Router Mounting + Path Rewriting** -The application handles routing by registering storefront routes **twice** with different prefixes: +The application handles routing by registering both storefront and store dashboard routes **twice** with different prefixes: ```python -# In main.py +# In main.py — Storefront routes (customer-facing) app.include_router(storefront_pages.router, prefix="/storefront") app.include_router(storefront_pages.router, prefix="/storefront/{store_code}") + +# In main.py — Store dashboard routes (staff management) +app.include_router(store_pages.router, prefix="/store") +app.include_router(store_pages.router, prefix="/store/{store_code}") ``` **How This Works:** 1. **For Subdomain/Custom Domain Mode (Production):** - - URL: `https://acme.omsflow.lu/products` - - `PlatformContextMiddleware` detects subdomain, rewrites path: `/products` → `/storefront/products` - - Matches: First router with `/storefront` prefix - - Route: `@router.get("/products")` → Full path: `/storefront/products` + - Storefront: `https://acme.omsflow.lu/products` → path rewritten to `/storefront/products` → matches first storefront mount + - Dashboard: `https://acme.omsflow.lu/store/dashboard` → matches first store mount at `/store` + - Store resolved by middleware via `request.state.store` 2. **For Path-Based Development Mode:** - - URL: `http://localhost:8000/platforms/oms/storefront/ACME/products` - - Platform middleware strips `/platforms/oms/` prefix, sets platform context - - Matches: Second router with `/storefront/{store_code}` prefix - - Route: `@router.get("/products")` → Full path: `/storefront/{store_code}/products` - - Bonus: `store_code` available as path parameter! + - Storefront: `http://localhost:8000/platforms/oms/storefront/ACME/products` → matches second storefront mount at `/storefront/{store_code}` + - Dashboard: `http://localhost:8000/platforms/oms/store/ACME/dashboard` → matches second store mount at `/store/{store_code}` + - `store_code` available as path parameter + +### `get_resolved_store_code` Dependency + +Route handlers use the `get_resolved_store_code` dependency to transparently obtain the store code regardless of deployment mode: + +```python +async def get_resolved_store_code(request: Request) -> str: + # 1. Path parameter from double-mount (/store/{store_code}/...) + store_code = request.path_params.get("store_code") + if store_code: + return store_code + # 2. Middleware-resolved store (subdomain or custom domain) + store = getattr(request.state, "store", None) + if store: + return store.store_code + raise HTTPException(status_code=404, detail="Store not found") +``` **Benefits:** - ✅ Clean separation: `/storefront/` = customer, `/store/` = staff @@ -553,6 +588,43 @@ app.include_router(storefront_pages.router, prefix="/storefront/{store_code}") - ✅ No `/storefront/` prefix visible to production customers - ✅ Internal path rewriting handled by ASGI middleware - ✅ Both deployment modes supported cleanly +- ✅ `get_resolved_store_code` abstracts store resolution for handlers + +--- + +## Per-Platform Subdomain Overrides + +Stores that are active on multiple platforms can have a **custom subdomain** per platform via `StorePlatform.custom_subdomain`. This allows a single store to appear under different subdomains on different platform domains. + +### How It Works + +``` +Store: WizaTech (subdomain: "wizatech") +├── OMS platform → wizatech.omsflow.lu (uses Store.subdomain) +└── Loyalty platform → wizatech-rewards.rewardflow.lu (uses StorePlatform.custom_subdomain) +``` + +**Database:** +```sql +-- stores table +id | store_code | subdomain +1 | WIZATECH | wizatech + +-- store_platforms table +id | store_id | platform_id | custom_subdomain +1 | 1 | 1 (oms) | NULL -- uses store.subdomain = "wizatech" +2 | 1 | 2 (loyalty) | wizatech-rewards -- overrides to "wizatech-rewards" +``` + +**Resolution order** (in `store_context_middleware`): +1. Check `StorePlatform.custom_subdomain` for a match on the current platform +2. Fall back to `Store.subdomain` for the standard lookup + +### Use Cases + +- **Brand differentiation**: A store selling electronics via OMS and running a loyalty program wants different branding per platform +- **Subdomain conflicts**: Two unrelated stores might use the same subdomain on different platforms — custom subdomains resolve the collision +- **Marketing**: Platform-specific landing URLs for campaigns (e.g., `wizatech-rewards.rewardflow.lu` for loyalty-specific promotions) --- @@ -592,5 +664,5 @@ Set-Cookie: customer_token=eyJ...; Path=/storefront; HttpOnly; SameSite=Lax --- -Generated: January 30, 2026 +Generated: February 26, 2026 Orion Version: Current Development diff --git a/docs/development/database-seeder/database-seeder-documentation.md b/docs/development/database-seeder/database-seeder-documentation.md index a5694538..6685776b 100644 --- a/docs/development/database-seeder/database-seeder-documentation.md +++ b/docs/development/database-seeder/database-seeder-documentation.md @@ -140,11 +140,19 @@ This runs: ### Stores Created -| Code | Name | Subdomain | Theme | Custom Domain | -|------|------|-----------|-------|---------------| -| ORION | Orion | orion | modern | orion.shop | -| FASHIONHUB | Fashion Hub | fashionhub | vibrant | fashionhub.store | -| BOOKSTORE | The Book Store | bookstore | classic | (none) | +| Code | Name | Subdomain | Theme | Custom Domain | Notes | +|------|------|-----------|-------|---------------|-------| +| WIZATECH | WizaTech | wizatech | modern | wizatech.shop (OMS) | `custom_subdomain="wizatech-rewards"` on Loyalty `StorePlatform` | +| WIZAGADGETS | WizaGadgets | wizagadgets | modern | (none) | | +| WIZAHOME | WizaHome | wizahome | classic | (none) | | +| FASHIONHUB | Fashion Hub | fashionhub | vibrant | fashionhub.store (Loyalty) | | +| FASHIONOUTLET | Fashion Outlet | fashionoutlet | vibrant | (none) | | +| BOOKSTORE | The Book Store | bookstore | classic | (none) | | +| BOOKDIGITAL | BookWorld Digital | bookdigital | modern | (none) | | + +**Per-platform subdomain demo:** WizaTech has both OMS and Loyalty subscriptions. On the Loyalty platform, its `StorePlatform.custom_subdomain` is set to `"wizatech-rewards"`, meaning `wizatech-rewards.rewardflow.lu` resolves to the same store as `wizatech.omsflow.lu`. + +**Multi-platform custom domains:** `StoreDomain` records now include a `platform_id` linking the domain to a specific platform. For example, `wizatech.shop` is linked to the OMS platform and `fashionhub.store` is linked to the Loyalty platform. ### Products Created @@ -217,15 +225,15 @@ python scripts/seed_database.py [--reset] [--minimal] - Username: `admin` - Password: `admin123` -### Store Storefronts -- ORION: `http://localhost:8000/storefront/ORION` -- FASHIONHUB: `http://localhost:8000/storefront/FASHIONHUB` -- BOOKSTORE: `http://localhost:8000/storefront/BOOKSTORE` +### Store Storefronts (Development) +- WIZATECH (OMS): `http://localhost:8000/platforms/oms/storefront/WIZATECH/` +- WIZATECH (Loyalty): `http://localhost:8000/platforms/loyalty/storefront/WIZATECH/` +- FASHIONHUB: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/` +- BOOKSTORE: `http://localhost:8000/platforms/oms/storefront/BOOKSTORE/` -### Theme Editors -- ORION Theme: `http://localhost:8000/admin/stores/ORION/theme` -- FASHIONHUB Theme: `http://localhost:8000/admin/stores/FASHIONHUB/theme` -- BOOKSTORE Theme: `http://localhost:8000/admin/stores/BOOKSTORE/theme` +### Store Dashboards (Development) +- WIZATECH: `http://localhost:8000/platforms/oms/store/WIZATECH/dashboard` +- FASHIONHUB: `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/dashboard` ## Example Output diff --git a/scripts/seed/seed_demo.py b/scripts/seed/seed_demo.py index 04d18735..502caafa 100644 --- a/scripts/seed/seed_demo.py +++ b/scripts/seed/seed_demo.py @@ -152,6 +152,10 @@ DEMO_STORES = [ "description": "Premium electronics and gadgets marketplace", "theme_preset": "modern", "custom_domain": "wizatech.shop", + "custom_domain_platform": "oms", # Link domain to OMS platform + "platform_subdomains": { # Per-platform subdomain overrides + "loyalty": "wizatech-rewards", # wizatech-rewards.rewardflow.lu + }, }, { "merchant_index": 0, # WizaCorp @@ -179,6 +183,7 @@ DEMO_STORES = [ "description": "Trendy clothing and accessories", "theme_preset": "vibrant", "custom_domain": "fashionhub.store", + "custom_domain_platform": "loyalty", # Link domain to Loyalty platform }, { "merchant_index": 1, # Fashion Group @@ -799,12 +804,28 @@ def create_demo_stores( ) ).all() + # Build platform code→id lookup for this store's custom subdomain config + from app.modules.tenancy.models import Platform + + platform_code_map = {} + if store_data.get("platform_subdomains") or store_data.get("custom_domain_platform"): + platform_rows = db.execute(select(Platform.id, Platform.code)).all() + platform_code_map = {code: pid for pid, code in platform_rows} + for i, (platform_id,) in enumerate(merchant_subs): + # Per-platform subdomain override for multi-platform stores + # Config uses platform codes; resolve to IDs + custom_sub = None + for pcode, subdomain_val in store_data.get("platform_subdomains", {}).items(): + if platform_code_map.get(pcode) == platform_id: + custom_sub = subdomain_val + break sp = StorePlatform( store_id=store.id, platform_id=platform_id, is_active=True, is_primary=(i == 0), + custom_subdomain=custom_sub, ) db.add(sp) @@ -814,6 +835,9 @@ def create_demo_stores( f" Linked to {len(merchant_subs)} platform(s): " f"{[pid for (pid,) in merchant_subs]}" ) + # Report custom subdomains if any + for pcode, subdomain_val in store_data.get("platform_subdomains", {}).items(): + print_success(f" Custom subdomain on {pcode}: {subdomain_val}") # Owner relationship is via Merchant.owner_user_id — no StoreUser needed @@ -839,11 +863,15 @@ def create_demo_stores( # Create custom domain if specified if store_data.get("custom_domain"): + # Resolve platform_id from platform code (if specified) + domain_platform_code = store_data.get("custom_domain_platform") + domain_platform_id = platform_code_map.get(domain_platform_code) if domain_platform_code else None domain = StoreDomain( store_id=store.id, domain=store_data[ "custom_domain" ], # ✅ Field is 'domain', not 'domain_name' + platform_id=domain_platform_id, is_verified=True, # Auto-verified for demo is_primary=True, verification_token=None, @@ -1290,6 +1318,20 @@ def print_summary(db: Session): print(f"\n {store.name} ({store.store_code})") print(f" Subdomain: {store.subdomain}.{settings.platform_domain}") + # Show per-platform custom subdomains + from app.modules.tenancy.models import Platform + + store_platforms = ( + db.query(StorePlatform, Platform) + .join(Platform, Platform.id == StorePlatform.platform_id) + .filter(StorePlatform.store_id == store.id) + .all() + ) + for sp, platform in store_platforms: + if sp.custom_subdomain: + pdomain = getattr(platform, "domain", platform.code) + print(f" [{platform.code}] Custom subdomain: {sp.custom_subdomain}.{pdomain}") + # Query custom domains separately custom_domain = ( db.query(StoreDomain) @@ -1305,7 +1347,12 @@ def print_summary(db: Session): or getattr(custom_domain, "name", None) ) if domain_value: - print(f" Custom: {domain_value}") + platform_note = "" + if custom_domain.platform_id: + dp = db.query(Platform).filter(Platform.id == custom_domain.platform_id).first() + if dp: + platform_note = f" (linked to {dp.code})" + print(f" Custom: {domain_value}{platform_note}") print(f" Status: {'✓ Active' if store.is_active else '✗ Inactive'}") @@ -1375,6 +1422,25 @@ def print_summary(db: Session): port = settings.api_port base = f"http://localhost:{port}" + # Build store→platform details (including custom subdomains) for production URLs + from app.modules.tenancy.models import Platform + + store_platform_details: dict[int, list[dict]] = {} + sp_detail_rows = db.execute( + select( + StorePlatform.store_id, + Platform.code, + Platform.domain, + StorePlatform.custom_subdomain, + ).join(Platform, Platform.id == StorePlatform.platform_id) + .where(StorePlatform.is_active == True) # noqa: E712 + .order_by(StorePlatform.store_id, StorePlatform.is_primary.desc()) + ).all() + for store_id, pcode, pdomain, custom_sub in sp_detail_rows: + store_platform_details.setdefault(store_id, []).append({ + "code": pcode, "domain": pdomain, "custom_subdomain": custom_sub, + }) + print("\n🏪 Store Access (Development):") print("─" * 70) for store in stores: @@ -1388,6 +1454,28 @@ def print_summary(db: Session): print(f" [{pc}] Staff login: {base}/platforms/{pc}/store/{store.store_code}/login") else: print(" (!) No platform assigned") + + print("\n🌐 Store Access (Production-style):") + print("─" * 70) + for store in stores: + details = store_platform_details.get(store.id, []) + print(f" {store.name} ({store.store_code}):") + for d in details: + subdomain = d["custom_subdomain"] or store.subdomain + pdomain = d["domain"] or f"{d['code']}.example.com" + suffix = " (custom subdomain)" if d["custom_subdomain"] else "" + print(f" [{d['code']}] Storefront: https://{subdomain}.{pdomain}/{suffix}") + print(f" [{d['code']}] Dashboard: https://{subdomain}.{pdomain}/store/dashboard") + # Show custom domain if any + custom_domain = ( + db.query(StoreDomain) + .filter(StoreDomain.store_id == store.id, StoreDomain.is_active == True) + .first() + ) + if custom_domain: + dv = getattr(custom_domain, "domain", None) + if dv: + print(f" [custom] Storefront: https://{dv}/") print() print("⚠️ ALL DEMO CREDENTIALS ARE INSECURE - For development only!")