feat: platform-aware storefront routing and billing improvements
Overhaul storefront URL routing to be platform-aware:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (internally rewritten to /storefront/)
- Add subdomain detection in PlatformContextMiddleware
- Add /storefront/ path rewrite for prod mode (subdomain/custom domain)
- Remove all silent platform fallbacks (platform_id=1)
- Add require_platform dependency for clean endpoint validation
- Update route registration, templates, module definitions, base_url calc
- Update StoreContextMiddleware for /storefront/ path detection
- Remove /stores/ from FrontendDetector STOREFRONT_PATH_PREFIXES
Billing service improvements:
- Add store_platform_sync_service to keep store_platforms in sync
- Make tier lookups platform-aware across billing services
- Add tiers for all platforms in seed data
- Add demo subscriptions to seed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,8 +29,11 @@ from app.modules.tenancy.models import Platform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default platform code for main marketing site
|
||||
DEFAULT_PLATFORM_CODE = "main"
|
||||
# Platform code for the main marketing site (localhost without /platforms/ prefix)
|
||||
MAIN_PLATFORM_CODE = "main"
|
||||
|
||||
# Hosts treated as local development (including Starlette TestClient's "testserver")
|
||||
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "testserver"}
|
||||
|
||||
|
||||
class PlatformContextManager:
|
||||
@@ -68,7 +71,7 @@ class PlatformContextManager:
|
||||
# Method 1: Domain-based detection (production)
|
||||
# Check if the host matches a known platform domain
|
||||
# This will be resolved in get_platform_from_context by DB lookup
|
||||
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
|
||||
if host_without_port and host_without_port not in _LOCAL_HOSTS:
|
||||
# Could be a platform domain or a store subdomain/custom domain
|
||||
# Check if it's a known platform domain pattern
|
||||
# For now, assume non-localhost hosts that aren't subdomains are platform domains
|
||||
@@ -108,11 +111,11 @@ class PlatformContextManager:
|
||||
# Method 3: Default platform for localhost without /platforms/ prefix
|
||||
# This serves the main marketing site
|
||||
# Store routes require explicit platform via /platforms/{code}/store/...
|
||||
if host_without_port in ["localhost", "127.0.0.1"]:
|
||||
if host_without_port in _LOCAL_HOSTS:
|
||||
if path.startswith(("/store/", "/stores/")):
|
||||
return None # No platform — handlers will show appropriate error
|
||||
return {
|
||||
"path_prefix": DEFAULT_PLATFORM_CODE,
|
||||
"path_prefix": MAIN_PLATFORM_CODE,
|
||||
"detection_method": "default",
|
||||
"host": host,
|
||||
"original_path": path,
|
||||
@@ -136,8 +139,8 @@ class PlatformContextManager:
|
||||
|
||||
platform = None
|
||||
|
||||
# Method 1: Domain-based lookup
|
||||
if context.get("detection_method") == "domain":
|
||||
# Method 1: Domain-based lookup (also handles subdomain detection)
|
||||
if context.get("detection_method") in ("domain", "subdomain"):
|
||||
domain = context.get("domain")
|
||||
if domain:
|
||||
# Try Platform.domain first
|
||||
@@ -168,6 +171,8 @@ class PlatformContextManager:
|
||||
.first()
|
||||
)
|
||||
if platform:
|
||||
# Mark as store domain so __call__ knows to rewrite path
|
||||
context["is_store_domain"] = True
|
||||
logger.debug(
|
||||
f"[PLATFORM] Platform found via store domain: {domain} → {platform.name}"
|
||||
)
|
||||
@@ -194,6 +199,8 @@ class PlatformContextManager:
|
||||
.first()
|
||||
)
|
||||
if platform:
|
||||
# Mark as store domain so __call__ knows to rewrite path
|
||||
context["is_store_domain"] = True
|
||||
logger.debug(
|
||||
f"[PLATFORM] Platform found via merchant domain: {domain} → {platform.name}"
|
||||
)
|
||||
@@ -226,7 +233,7 @@ class PlatformContextManager:
|
||||
if context.get("detection_method") == "default":
|
||||
platform = (
|
||||
db.query(Platform)
|
||||
.filter(Platform.code == DEFAULT_PLATFORM_CODE)
|
||||
.filter(Platform.code == MAIN_PLATFORM_CODE)
|
||||
.filter(Platform.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
@@ -366,12 +373,33 @@ class PlatformContextMiddleware:
|
||||
|
||||
# REWRITE THE PATH for routing
|
||||
# This is the key: FastAPI will route based on this rewritten path
|
||||
if platform_context.get("detection_method") == "path":
|
||||
detection_method = platform_context.get("detection_method")
|
||||
|
||||
if detection_method == "path":
|
||||
# Dev mode: strip /platforms/{code}/ prefix
|
||||
scope["path"] = clean_path
|
||||
# Also update raw_path if present
|
||||
if "raw_path" in scope:
|
||||
scope["raw_path"] = clean_path.encode("utf-8")
|
||||
|
||||
# Prod mode: subdomain or custom store domain
|
||||
# Prepend /storefront/ so routes registered at /storefront/ prefix match
|
||||
is_storefront_domain = (
|
||||
detection_method == "subdomain"
|
||||
or platform_context.get("is_store_domain", False)
|
||||
)
|
||||
if is_storefront_domain:
|
||||
_RESERVED = (
|
||||
"/store/", "/admin/", "/api/", "/static/",
|
||||
"/storefront/", "/health", "/docs", "/redoc",
|
||||
"/media/", "/assets/",
|
||||
)
|
||||
if not any(clean_path.startswith(p) for p in _RESERVED):
|
||||
new_path = "/storefront" + clean_path
|
||||
scope["path"] = new_path
|
||||
scope["state"]["platform_clean_path"] = new_path
|
||||
if "raw_path" in scope:
|
||||
scope["raw_path"] = new_path.encode("utf-8")
|
||||
|
||||
logger.debug(
|
||||
f"[PLATFORM] Detected: {platform.code}, "
|
||||
f"original={path}, routed={scope['path']}"
|
||||
@@ -406,7 +434,7 @@ class PlatformContextMiddleware:
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
|
||||
# Method 1: Domain-based (production)
|
||||
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
|
||||
if host_without_port and host_without_port not in _LOCAL_HOSTS:
|
||||
if "." in host_without_port:
|
||||
parts = host_without_port.split(".")
|
||||
if len(parts) == 2: # Root domain like omsflow.lu
|
||||
@@ -415,7 +443,17 @@ class PlatformContextMiddleware:
|
||||
"detection_method": "domain",
|
||||
"host": host,
|
||||
"original_path": path,
|
||||
"clean_path": path, # No path rewrite for domain-based
|
||||
"clean_path": path,
|
||||
}
|
||||
if len(parts) >= 3: # Subdomain like wizatech.omsflow.lu
|
||||
root_domain = ".".join(parts[-2:])
|
||||
return {
|
||||
"domain": root_domain,
|
||||
"subdomain": parts[0],
|
||||
"detection_method": "subdomain",
|
||||
"host": host,
|
||||
"original_path": path,
|
||||
"clean_path": path,
|
||||
}
|
||||
|
||||
# Method 2: Path-based (development) - ONLY for /platforms/ prefix
|
||||
@@ -436,12 +474,12 @@ class PlatformContextMiddleware:
|
||||
}
|
||||
|
||||
# Method 3: Default for localhost - serves main marketing site
|
||||
# Store routes require explicit platform via /platforms/{code}/store/...
|
||||
if host_without_port in ["localhost", "127.0.0.1"]:
|
||||
if path.startswith(("/store/", "/stores/")):
|
||||
return None # No platform — handlers will show appropriate error
|
||||
# Store/storefront routes require explicit platform via /platforms/{code}/...
|
||||
if host_without_port in _LOCAL_HOSTS:
|
||||
if path.startswith(("/store/", "/stores/", "/storefront/")):
|
||||
return None # No platform — require /platforms/{code}/ prefix
|
||||
return {
|
||||
"path_prefix": DEFAULT_PLATFORM_CODE,
|
||||
"path_prefix": MAIN_PLATFORM_CODE,
|
||||
"detection_method": "default",
|
||||
"host": host,
|
||||
"original_path": path,
|
||||
|
||||
Reference in New Issue
Block a user