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

@@ -0,0 +1,35 @@
"""Remove is_primary from store_platforms
The platform is always deterministic from the URL context (path in dev,
subdomain/domain in prod) and the JWT carries token_platform_id.
The is_primary column was a fallback picker that silently returned the
wrong platform for multi-platform stores.
Revision ID: remove_is_primary_001
Revises: billing_001
Create Date: 2026-03-09
"""
import sqlalchemy as sa
from alembic import op
revision = "remove_is_primary_001"
down_revision = "billing_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("idx_store_platform_primary", table_name="store_platforms")
op.drop_column("store_platforms", "is_primary")
def downgrade() -> None:
op.add_column(
"store_platforms",
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index(
"idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]
)

View File

@@ -103,9 +103,12 @@ class RequireFeature:
) -> None:
"""Check if store's merchant has access to any of the required features."""
store_id = current_user.token_store_id
platform_id = current_user.token_platform_id
for feature_code in self.feature_codes:
if feature_service.has_feature_for_store(db, store_id, feature_code):
if feature_service.has_feature_for_store(
db, store_id, feature_code, platform_id=platform_id
):
return
# None of the features are available
@@ -136,7 +139,8 @@ class RequireWithinLimit:
store_id = current_user.token_store_id
allowed, message = feature_service.check_resource_limit(
db, self.feature_code, store_id=store_id
db, self.feature_code, store_id=store_id,
platform_id=current_user.token_platform_id,
)
if not allowed:
@@ -176,9 +180,12 @@ def require_feature(*feature_codes: str) -> Callable:
)
store_id = current_user.token_store_id
platform_id = current_user.token_platform_id
for feature_code in feature_codes:
if feature_service.has_feature_for_store(db, store_id, feature_code):
if feature_service.has_feature_for_store(
db, store_id, feature_code, platform_id=platform_id
):
return await func(*args, **kwargs)
raise FeatureNotAvailableError(feature_code=feature_codes[0])
@@ -195,9 +202,12 @@ def require_feature(*feature_codes: str) -> Callable:
)
store_id = current_user.token_store_id
platform_id = current_user.token_platform_id
for feature_code in feature_codes:
if feature_service.has_feature_for_store(db, store_id, feature_code):
if feature_service.has_feature_for_store(
db, store_id, feature_code, platform_id=platform_id
):
return func(*args, **kwargs)
raise FeatureNotAvailableError(feature_code=feature_codes[0])

View File

@@ -44,7 +44,7 @@ def upgrade() -> None:
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), # Removed in migration remove_is_primary_001
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
@@ -53,7 +53,7 @@ def upgrade() -> None:
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
)
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"])
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) # Removed in migration remove_is_primary_001
# --- tier_feature_limits ---
op.create_table(

View File

@@ -46,7 +46,7 @@ def get_subscription_status(
):
"""Get current subscription status."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
@@ -83,7 +83,7 @@ def get_available_tiers(
):
"""Get available subscription tiers for upgrade/downgrade."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
current_tier_id = subscription.tier_id
@@ -105,7 +105,7 @@ def get_invoices(
):
"""Get invoice history."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)

View File

@@ -55,7 +55,7 @@ def create_checkout_session(
):
"""Create a Stripe checkout session for subscription."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
store_code = subscription_service.get_store_code(db, store_id)
@@ -84,7 +84,7 @@ def create_portal_session(
):
"""Create a Stripe customer portal session."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
store_code = subscription_service.get_store_code(db, store_id)
return_url = f"https://{settings.platform_domain}/store/{store_code}/billing"
@@ -102,7 +102,7 @@ def cancel_subscription(
):
"""Cancel subscription."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
result = billing_service.cancel_subscription(
db=db,
@@ -126,7 +126,7 @@ def reactivate_subscription(
):
"""Reactivate a cancelled subscription."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
db.commit()
@@ -141,7 +141,7 @@ def get_upcoming_invoice(
):
"""Preview the upcoming invoice."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
@@ -161,7 +161,7 @@ def change_tier(
):
"""Change subscription tier (upgrade/downgrade)."""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
result = billing_service.change_tier(
db=db,

View File

@@ -95,7 +95,7 @@ def get_available_features(
List of feature codes the store has access to
"""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
# Get available feature codes
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
@@ -134,7 +134,7 @@ def get_features(
List of features with metadata and availability
"""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
# Get all declarations and available codes
all_declarations = feature_aggregator.get_all_declarations()
@@ -197,7 +197,7 @@ def get_features_grouped(
Useful for rendering feature comparison tables or settings pages.
"""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
# Get declarations grouped by category and available codes
by_category = feature_aggregator.get_declarations_by_category()
@@ -246,7 +246,9 @@ def check_feature(
has_feature and feature_code
"""
store_id = current_user.token_store_id
has = feature_service.has_feature_for_store(db, store_id, feature_code)
has = feature_service.has_feature_for_store(
db, store_id, feature_code, platform_id=current_user.token_platform_id
)
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
@@ -270,7 +272,7 @@ def get_feature_detail(
Feature details with upgrade info if locked
"""
store_id = current_user.token_store_id
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
# Get feature declaration
decl = feature_aggregator.get_declaration(feature_code)

View File

@@ -108,10 +108,17 @@ class FeatureService:
# Store -> Merchant Resolution
# =========================================================================
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
def _get_merchant_for_store(
self, db: Session, store_id: int, platform_id: int | None = None
) -> tuple[int | None, int | None]:
"""
Resolve store_id to (merchant_id, platform_id).
Args:
db: Database session
store_id: Store ID
platform_id: Platform ID from JWT. When provided, skips DB lookup.
Returns:
Tuple of (merchant_id, platform_id), either may be None
"""
@@ -123,7 +130,8 @@ class FeatureService:
return None, None
merchant_id = store.merchant_id
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
if platform_id is None:
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
return merchant_id, platform_id
@@ -204,28 +212,29 @@ class FeatureService:
return subscription.tier.has_feature(feature_code)
def has_feature_for_store(
self, db: Session, store_id: int, feature_code: str
self, db: Session, store_id: int, feature_code: str,
platform_id: int | None = None,
) -> bool:
"""
Convenience method that resolves the store -> merchant -> platform
hierarchy and checks whether the merchant has access to a feature.
Looks up the store's merchant_id and platform_id, then delegates
to has_feature().
Args:
db: Database session.
store_id: The store ID to resolve.
feature_code: The feature code to check.
platform_id: Platform ID from JWT. When provided, skips DB lookup.
Returns:
True if the resolved merchant has access to the feature,
False if the store/merchant cannot be resolved or lacks access.
"""
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
if merchant_id is None or platform_id is None:
merchant_id, resolved_platform_id = self._get_merchant_for_store(
db, store_id, platform_id=platform_id
)
if merchant_id is None or resolved_platform_id is None:
return False
return self.has_feature(db, merchant_id, platform_id, feature_code)
return self.has_feature(db, merchant_id, resolved_platform_id, feature_code)
def get_merchant_feature_codes(
self, db: Session, merchant_id: int, platform_id: int
@@ -317,7 +326,7 @@ class FeatureService:
feature_code: Feature code (e.g., "products_limit")
store_id: Store ID (if checking per-store)
merchant_id: Merchant ID (if already known)
platform_id: Platform ID (if already known)
platform_id: Platform ID (if already known, e.g. from JWT)
Returns:
(allowed, error_message) tuple
@@ -326,7 +335,9 @@ class FeatureService:
# Resolve store -> merchant if needed
if merchant_id is None and store_id is not None:
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
merchant_id, platform_id = self._get_merchant_for_store(
db, store_id, platform_id=platform_id
)
if merchant_id is None or platform_id is None:
return False, "No subscription found"

View File

@@ -32,7 +32,7 @@ class StorePlatformSync:
Upsert StorePlatform for every store belonging to a merchant.
- Existing entry → update is_active (and tier_id if provided)
- Missing + is_active=True → create (set is_primary if store has none)
- Missing + is_active=True → create
- Missing + is_active=False → no-op
"""
stores = store_service.get_stores_by_merchant_id(db, merchant_id)

View File

@@ -311,6 +311,7 @@ class StripeService:
trial_days: int | None = None,
quantity: int = 1,
metadata: dict | None = None,
platform_id: int | None = None,
) -> stripe.checkout.Session:
"""
Create a Stripe Checkout session for subscription signup.
@@ -324,6 +325,7 @@ class StripeService:
trial_days: Optional trial period
quantity: Number of items (default 1)
metadata: Additional metadata to store
platform_id: Platform ID (from JWT or caller). Falls back to DB lookup.
Returns:
Stripe Checkout Session object
@@ -334,7 +336,8 @@ class StripeService:
from app.modules.tenancy.services.platform_service import platform_service
from app.modules.tenancy.services.team_service import team_service
platform_id = platform_service.get_primary_platform_id_for_store(db, store.id)
if platform_id is None:
platform_id = platform_service.get_first_active_platform_id_for_store(db, store.id)
subscription = None
if store.merchant_id and platform_id:
subscription = (

View File

@@ -47,9 +47,16 @@ class SubscriptionService:
# Store Resolution
# =========================================================================
def resolve_store_to_merchant(self, db: Session, store_id: int) -> tuple[int, int]:
def resolve_store_to_merchant(
self, db: Session, store_id: int, platform_id: int | None = None
) -> tuple[int, int]:
"""Resolve store_id to (merchant_id, platform_id).
Args:
db: Database session
store_id: Store ID
platform_id: Platform ID from JWT token. When provided, skips DB lookup.
Raises:
ResourceNotFoundException: If store not found or has no platform
"""
@@ -59,7 +66,8 @@ class SubscriptionService:
store = store_service.get_store_by_id_optional(db, store_id)
if not store or not store.merchant_id:
raise ResourceNotFoundException("Store", str(store_id))
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
if platform_id is None:
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
if not platform_id:
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
return store.merchant_id, platform_id
@@ -185,7 +193,7 @@ class SubscriptionService:
if merchant_id is None:
return None
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
if platform_id is None:
return None

View File

@@ -39,52 +39,6 @@ class TestStorePlatformSyncCreate:
assert sp is not None
assert sp.is_active is True
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
"""First platform synced for a store gets is_primary=True."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp.is_primary is True
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
"""Second platform synced does not override existing primary."""
# First platform becomes primary
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
# Second platform should not be primary
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, another_platform.id, is_active=True
)
sp1 = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
sp2 = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == another_platform.id,
)
.first()
)
assert sp1.is_primary is True
assert sp2.is_primary is False
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
"""Sync passes tier_id to newly created StorePlatform."""
self.service.sync_store_platforms_for_merchant(
@@ -118,7 +72,6 @@ class TestStorePlatformSyncUpdate:
store_id=test_store.id,
platform_id=test_platform.id,
is_active=True,
is_primary=True,
)
db.add(sp)
db.flush()
@@ -137,7 +90,6 @@ class TestStorePlatformSyncUpdate:
store_id=test_store.id,
platform_id=test_platform.id,
is_active=True,
is_primary=True,
)
db.add(sp)
db.flush()

View File

@@ -52,7 +52,7 @@ def list_store_pages(
Returns store-specific overrides + platform defaults (store overrides take precedence).
"""
platform_id = content_page_service.resolve_platform_id(db, current_user.token_store_id)
platform_id = current_user.token_platform_id or content_page_service.resolve_platform_id(db, current_user.token_store_id)
pages = content_page_service.list_pages_for_store(
db, platform_id=platform_id, store_id=current_user.token_store_id, include_unpublished=include_unpublished
)
@@ -176,7 +176,7 @@ def get_page(
Returns store override if exists, otherwise platform default.
"""
platform_id = content_page_service.resolve_platform_id(db, current_user.token_store_id)
platform_id = current_user.token_platform_id or content_page_service.resolve_platform_id(db, current_user.token_store_id)
page = content_page_service.get_page_for_store_or_raise(
db,
platform_id=platform_id,

View File

@@ -47,7 +47,7 @@ class ContentPageService:
@staticmethod
def resolve_platform_id(db: Session, store_id: int) -> int | None:
"""
Resolve platform_id from store's primary StorePlatform.
Resolve platform_id from store's first active StorePlatform.
Resolution order:
1. Primary StorePlatform for the store
@@ -62,7 +62,7 @@ class ContentPageService:
"""
from app.modules.tenancy.services.platform_service import platform_service
return platform_service.get_primary_platform_id_for_store(db, store_id)
return platform_service.get_first_active_platform_id_for_store(db, store_id)
@staticmethod
def resolve_platform_id_or_raise(db: Session, store_id: int) -> int:

View File

@@ -143,9 +143,11 @@ def get_onboarding_status(
store_id = current_user.token_store_id
store = store_service.get_store_by_id(db, store_id)
return onboarding_aggregator.get_onboarding_summary(
summary = onboarding_aggregator.get_onboarding_summary(
db=db,
store_id=store_id,
platform_id=platform.id,
store_code=store.store_code,
)
summary["is_owner"] = current_user.role == "merchant_owner"
return summary

View File

@@ -100,7 +100,7 @@ async def get_rendered_store_menu(
# Platform from JWT (set at login from URL context), fall back to DB for old tokens
platform_id = current_user.token_platform_id
if platform_id is None:
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
platform_id = menu_service.get_store_fallback_platform_id(db, store.id)
# Get filtered menu with platform visibility, store_code, and permission filtering
menu = menu_service.get_menu_for_rendering(

View File

@@ -422,7 +422,7 @@ class MenuService:
Get the primary platform ID for a merchant's visibility config.
Resolution order:
1. Platform from the store marked is_primary in StorePlatform
1. First active StorePlatform for the merchant's stores (by joined_at)
2. First active subscription's platform (fallback)
Args:
@@ -445,7 +445,7 @@ class MenuService:
# Try primary store platform first
for store in stores:
pid = platform_service.get_primary_platform_id_for_store(db, store.id)
pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
if pid is not None:
# Verify merchant has active subscription on this platform
active_pids = subscription_service.get_active_subscription_platform_ids(
@@ -460,16 +460,16 @@ class MenuService:
)
return active_pids[0] if active_pids else None
def get_store_primary_platform_id(
def get_store_fallback_platform_id(
self,
db: Session,
store_id: int,
) -> int | None:
"""
Get the primary platform ID for a store's menu visibility config.
Get the fallback platform ID for a store's menu visibility config.
Prefers the active StorePlatform marked is_primary, falls back to
the first active StorePlatform by ID.
Returns the first active StorePlatform ordered by joined_at.
Used only when platform_id is not available from JWT context.
Args:
db: Database session
@@ -480,7 +480,7 @@ class MenuService:
"""
from app.modules.tenancy.services.platform_service import platform_service
return platform_service.get_primary_platform_id_for_store(db, store_id)
return platform_service.get_first_active_platform_id_for_store(db, store_id)
def get_merchant_for_menu(
self,

View File

@@ -39,20 +39,6 @@ class OnboardingAggregatorService:
a unified interface for the dashboard onboarding banner.
"""
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
"""Get platform IDs the store is actively subscribed to."""
from app.modules.tenancy.models.store_platform import StorePlatform
rows = (
db.query(StorePlatform.platform_id)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.is_active.is_(True),
)
.all()
)
return {r.platform_id for r in rows}
def _get_enabled_providers(
self, db: Session, store_id: int, platform_id: int
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
@@ -68,9 +54,9 @@ class OnboardingAggregatorService:
from app.modules.registry import MODULES
from app.modules.service import module_service
store_platform_ids = self._get_store_platform_ids(db, store_id)
if not store_platform_ids:
# Fallback to the passed platform_id if no subscriptions found
# Only check the current platform, not all subscribed platforms.
# This prevents cross-platform content leakage (e.g. showing OMS steps
# when logged in on the loyalty platform).
store_platform_ids = {platform_id}
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []

View File

@@ -19,6 +19,7 @@ function onboardingBanner() {
return {
t,
visible: false,
isOwner: true,
steps: [],
totalSteps: 0,
completedSteps: 0,
@@ -33,6 +34,7 @@ function onboardingBanner() {
try {
const response = await apiClient.get('/store/dashboard/onboarding');
const steps = response.steps || [];
this.isOwner = response.is_owner !== false;
// Load module translations BEFORE setting reactive data
// Keys are like "tenancy.onboarding...." — first segment is the module

View File

@@ -35,6 +35,7 @@ dev_tools_module = ModuleDefinition(
"performance_validation", # Performance validator
"test_runner", # Test execution
"violation_management", # Violation tracking and assignment
"sql_query", # Ad-hoc SQL query tool
],
menu_items={
FrontendType.ADMIN: [
@@ -42,6 +43,7 @@ dev_tools_module = ModuleDefinition(
"icons", # Icon browser page
"code-quality", # Code quality dashboard
"tests", # Test runner dashboard
"sql-query", # SQL query tool
],
FrontendType.STORE: [], # No store menu items - internal module
},
@@ -69,6 +71,20 @@ dev_tools_module = ModuleDefinition(
route="/admin/icons",
order=20,
),
MenuItemDefinition(
id="sql-query",
label_key="dev_tools.menu.sql_query",
icon="database",
route="/admin/sql-query",
order=30,
),
MenuItemDefinition(
id="platform-debug",
label_key="dev_tools.menu.platform_debug",
icon="search",
route="/admin/platform-debug",
order=40,
),
],
),
],
@@ -94,11 +110,8 @@ def get_dev_tools_module_with_routers() -> ModuleDefinition:
"""
Get dev-tools module definition.
Note: API routes have been moved to monitoring module.
This module has no routers to attach.
Note: Admin API routes are auto-discovered from routes/api/admin.py.
"""
# No routers - API routes are now in monitoring module
dev_tools_module.router = None
dev_tools_module.router = None
return dev_tools_module

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Entwicklerwerkzeuge",
"components": "Komponenten",
"icons": "Icons"
"icons": "Icons",
"sql_query": "SQL Abfrage",
"platform_debug": "Plattform Debug"
},
"sql_query": {
"title": "SQL Abfrage-Werkzeug",
"execute": "Abfrage ausführen",
"save": "Abfrage speichern",
"export_csv": "CSV exportieren",
"saved_queries": "Gespeicherte Abfragen",
"no_saved_queries": "Noch keine gespeicherten Abfragen.",
"query_name": "Abfragename",
"description": "Beschreibung",
"forbidden_keyword": "Verbotenes SQL-Schlüsselwort. Nur SELECT-Abfragen sind erlaubt.",
"query_empty": "Abfrage darf nicht leer sein.",
"rows_returned": "Zeilen zurückgegeben",
"results_truncated": "Ergebnisse auf 1000 Zeilen beschränkt",
"execution_time": "Ausführungszeit"
}
}

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Developer Tools",
"components": "Components",
"icons": "Icons"
"icons": "Icons",
"sql_query": "SQL Query",
"platform_debug": "Platform Debug"
},
"sql_query": {
"title": "SQL Query Tool",
"execute": "Run Query",
"save": "Save Query",
"export_csv": "Export CSV",
"saved_queries": "Saved Queries",
"no_saved_queries": "No saved queries yet.",
"query_name": "Query Name",
"description": "Description",
"forbidden_keyword": "Forbidden SQL keyword. Only SELECT queries are allowed.",
"query_empty": "Query cannot be empty.",
"rows_returned": "rows returned",
"results_truncated": "Results truncated to 1000 rows",
"execution_time": "Execution time"
}
}

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Outils de développement",
"components": "Composants",
"icons": "Icônes"
"icons": "Icônes",
"sql_query": "Requête SQL",
"platform_debug": "Debug Plateforme"
},
"sql_query": {
"title": "Outil de requête SQL",
"execute": "Exécuter la requête",
"save": "Enregistrer la requête",
"export_csv": "Exporter en CSV",
"saved_queries": "Requêtes enregistrées",
"no_saved_queries": "Aucune requête enregistrée.",
"query_name": "Nom de la requête",
"description": "Description",
"forbidden_keyword": "Mot-clé SQL interdit. Seules les requêtes SELECT sont autorisées.",
"query_empty": "La requête ne peut pas être vide.",
"rows_returned": "lignes retournées",
"results_truncated": "Résultats tronqués à 1000 lignes",
"execution_time": "Temps d'exécution"
}
}

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Entwécklerwerkzäicher",
"components": "Komponenten",
"icons": "Icons"
"icons": "Icons",
"sql_query": "SQL Ufro",
"platform_debug": "Plattform Debug"
},
"sql_query": {
"title": "SQL Ufro-Werkzeug",
"execute": "Ufro ausféieren",
"save": "Ufro späicheren",
"export_csv": "CSV exportéieren",
"saved_queries": "Gespäichert Ufroen",
"no_saved_queries": "Nach keng gespäichert Ufroen.",
"query_name": "Ufroennumm",
"description": "Beschreiwung",
"forbidden_keyword": "Verbuedent SQL-Schlësselwuert. Nëmmen SELECT-Ufroen sinn erlaabt.",
"query_empty": "Ufro däerf net eidel sinn.",
"rows_returned": "Zeilen zréckginn",
"results_truncated": "Resultater op 1000 Zeilen beschränkt",
"execution_time": "Ausféierungszäit"
}
}

View File

@@ -0,0 +1,47 @@
"""dev_tools saved queries
Revision ID: dev_tools_002
Revises: dev_tools_001
Create Date: 2026-03-10
"""
import sqlalchemy as sa
from alembic import op
revision = "dev_tools_002"
down_revision = "dev_tools_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"dev_tools_saved_queries",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("name", sa.String(200), nullable=False, index=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("sql_text", sa.Text(), nullable=False),
sa.Column(
"created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"run_count", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("dev_tools_saved_queries")

View File

@@ -25,6 +25,7 @@ from app.modules.dev_tools.models.architecture_scan import (
ViolationAssignment,
ViolationComment,
)
from app.modules.dev_tools.models.saved_query import SavedQuery
from app.modules.dev_tools.models.test_run import (
TestCollection,
TestResult,
@@ -42,4 +43,6 @@ __all__ = [
"TestRun",
"TestResult",
"TestCollection",
# Saved query models
"SavedQuery",
]

View File

@@ -0,0 +1,34 @@
# app/modules/dev_tools/models/saved_query.py
"""
Saved SQL Query Model
Database model for storing frequently-used SQL queries.
"""
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from app.core.database import Base
class SavedQuery(Base):
"""A saved SQL query for quick re-running from the admin UI."""
__tablename__ = "dev_tools_saved_queries"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, index=True)
description = Column(Text, nullable=True)
sql_text = Column(Text, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
last_run_at = Column(DateTime(timezone=True), nullable=True)
run_count = Column(Integer, default=0, server_default="0")
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)

View File

@@ -6,5 +6,9 @@ Note: Code quality and test running routes have been moved to the monitoring mod
The dev_tools module keeps models but routes are now in monitoring.
"""
# No routes exported - code quality and tests are in monitoring module
__all__ = []
from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router,
)
from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router
__all__ = ["sql_query_router", "platform_debug_router"]

View File

@@ -0,0 +1,17 @@
# app/modules/dev_tools/routes/api/admin.py
"""
Dev-Tools module admin API routes.
Aggregates all admin API routers for the dev-tools module.
"""
from fastapi import APIRouter
from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router,
)
from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router
router = APIRouter()
router.include_router(sql_query_router, tags=["sql-query"])
router.include_router(platform_debug_router, tags=["platform-debug"])

View File

@@ -0,0 +1,344 @@
# app/modules/dev_tools/routes/api/admin_platform_debug.py
"""
Platform resolution debug endpoint.
Simulates the middleware pipeline for arbitrary host/path combos
to diagnose platform context issues across all URL patterns.
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_super_admin_api, get_db
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/debug")
logger = logging.getLogger(__name__)
class PlatformTraceStep(BaseModel):
step: str
result: dict | None = None
note: str = ""
class PlatformTraceResponse(BaseModel):
input_host: str
input_path: str
input_platform_code_body: str | None = None
steps: list[PlatformTraceStep]
resolved_platform_code: str | None = None
resolved_platform_id: int | None = None
resolved_store_code: str | None = None
resolved_store_id: int | None = None
login_platform_source: str | None = None
login_platform_code: str | None = None
@router.get("/platform-trace", response_model=PlatformTraceResponse)
def trace_platform_resolution(
host: str = Query(..., description="Host header to simulate (e.g. localhost:8000, www.omsflow.lu)"),
path: str = Query(..., description="URL path to simulate (e.g. /platforms/loyalty/store/WIZATECH/login)"),
platform_code_body: str | None = Query(None, description="platform_code sent in login request body (Source 2)"),
store_code_body: str | None = Query(None, description="store_code sent in login request body"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin_api),
):
"""
Simulate the full middleware + login platform resolution pipeline.
Traces each step:
1. PlatformContextMiddleware detection
2. Platform DB lookup
3. Path rewrite
4. StoreContextMiddleware detection
5. Store DB lookup
6. Login handler Source 1/2/3 resolution
"""
from middleware.platform_context import (
_LOCAL_HOSTS,
MAIN_PLATFORM_CODE,
PlatformContextMiddleware,
)
from middleware.store_context import StoreContextManager
steps = []
mw = PlatformContextMiddleware(None)
# ── Step 1: Platform detection ──
platform_context = mw._detect_platform_context(path, host)
steps.append(PlatformTraceStep(
step="1. PlatformContextMiddleware._detect_platform_context()",
result=_sanitize(platform_context),
note="Returns None for admin routes or /store/ on localhost without /platforms/ prefix",
))
# ── Step 1b: Referer fallback (storefront API on localhost) ──
host_without_port = host.split(":")[0] if ":" in host else host
if (
host_without_port in _LOCAL_HOSTS
and path.startswith("/api/v1/storefront/")
and platform_context
and platform_context.get("detection_method") == "default"
):
steps.append(PlatformTraceStep(
step="1b. Referer fallback check",
note="Would check Referer header for /platforms/{code}/ — not simulated here",
))
# ── Step 2: Platform DB lookup ──
from middleware.platform_context import PlatformContextManager
platform = None
if platform_context:
platform = PlatformContextManager.get_platform_from_context(db, platform_context)
platform_dict = None
if platform:
platform_dict = {
"id": platform.id,
"code": platform.code,
"name": platform.name,
"domain": platform.domain,
"path_prefix": platform.path_prefix,
}
steps.append(PlatformTraceStep(
step="2. PlatformContextManager.get_platform_from_context()",
result=platform_dict,
note="DB lookup using detection context" if platform_context else "Skipped — no platform_context",
))
# ── Step 3: Path rewrite ──
clean_path = path
if platform_context and platform_context.get("detection_method") == "path":
clean_path = platform_context.get("clean_path", path)
steps.append(PlatformTraceStep(
step="3. Path rewrite (scope['path'])",
result={"original_path": path, "rewritten_path": clean_path},
note="Dev mode strips /platforms/{code}/ prefix for routing"
if clean_path != path
else "No rewrite — path unchanged",
))
# ── Step 4: StoreContextMiddleware detection ──
# Simulate what StoreContextManager.detect_store_context() would see
# It reads request.state.platform_clean_path which is set to clean_path
store_context = _simulate_store_detection(clean_path, host)
if store_context and platform:
store_context["_platform"] = platform
steps.append(PlatformTraceStep(
step="4. StoreContextManager.detect_store_context()",
result=_sanitize(store_context),
note="Uses rewritten path (platform_clean_path) for detection",
))
# ── Step 5: Store DB lookup ──
store = None
if store_context:
store = StoreContextManager.get_store_from_context(db, store_context)
store_dict = None
if store:
store_dict = {
"id": store.id,
"store_code": store.store_code,
"subdomain": store.subdomain,
"name": store.name,
}
steps.append(PlatformTraceStep(
step="5. StoreContextManager.get_store_from_context()",
result=store_dict,
note="DB lookup using store context" if store_context else "Skipped — no store_context",
))
# ── Step 6: Is this an API path? ──
# The actual API path after rewrite determines middleware behavior
is_api = clean_path.startswith("/api/")
is_store_api = clean_path.startswith(("/store/", "/api/v1/store/"))
steps.append(PlatformTraceStep(
step="6. Route classification",
result={
"is_api_path": is_api,
"is_store_path": is_store_api,
"clean_path": clean_path,
"middleware_skips_store_detection_for_api": is_api and not clean_path.startswith("/api/v1/storefront/"),
},
note="StoreContextMiddleware SKIPS store detection for non-storefront API routes",
))
# ── Step 7: Login handler — Source 1/2/3 ──
from app.modules.tenancy.services.platform_service import platform_service
login_source = None
login_platform = None
login_platform_code = None
# Source 1: middleware platform (only if not "main")
src1_note = ""
if platform and platform.code != MAIN_PLATFORM_CODE:
login_platform = platform
login_source = "Source 1: middleware (request.state.platform)"
src1_note = f"platform.code={platform.code!r} != 'main' → used"
elif platform:
src1_note = f"platform.code={platform.code!r} == 'main' → skipped"
else:
src1_note = "No platform in middleware → skipped"
steps.append(PlatformTraceStep(
step="7a. Login Source 1: middleware platform",
result={"platform_code": platform.code if platform else None, "used": login_source is not None},
note=src1_note,
))
# Source 2: platform_code from body
src2_note = ""
if login_platform is None and platform_code_body:
body_platform = platform_service.get_platform_by_code_optional(db, platform_code_body)
if body_platform:
login_platform = body_platform
login_source = "Source 2: request body (platform_code)"
src2_note = f"Found platform with code={platform_code_body!r} → used"
else:
src2_note = f"No platform found for code={platform_code_body!r} → would raise error"
elif login_platform is not None:
src2_note = "Skipped — Source 1 already resolved"
else:
src2_note = "No platform_code in body → skipped"
steps.append(PlatformTraceStep(
step="7b. Login Source 2: request body platform_code",
result={"platform_code_body": platform_code_body, "used": login_source == "Source 2: request body (platform_code)"},
note=src2_note,
))
# Source 3: fallback to store's first active platform
src3_note = ""
if login_platform is None and store:
primary_pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
if primary_pid:
login_platform = platform_service.get_platform_by_id(db, primary_pid)
login_source = "Source 3: get_first_active_platform_id_for_store()"
src3_note = f"Fallback → first active platform for store {store.store_code}: platform_id={primary_pid}"
else:
src3_note = "No active platform for store"
elif login_platform is not None:
src3_note = "Skipped — earlier source already resolved"
# Still show what Source 3 WOULD return for comparison
if store:
primary_pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
if primary_pid:
fallback = platform_service.get_platform_by_id(db, primary_pid)
src3_note += f" (would have been: {fallback.code!r})"
else:
src3_note = "No store resolved — cannot determine fallback"
steps.append(PlatformTraceStep(
step="7c. Login Source 3: store's first active platform (fallback)",
result={
"store_id": store.id if store else None,
"used": login_source == "Source 3: get_first_active_platform_id_for_store()",
},
note=src3_note,
))
if login_platform:
login_platform_code = login_platform.code
# ── Step 8: Final result ──
steps.append(PlatformTraceStep(
step="8. FINAL LOGIN PLATFORM",
result={
"source": login_source,
"platform_code": login_platform_code,
"platform_id": login_platform.id if login_platform else None,
},
note=f"JWT will be minted with platform_code={login_platform_code!r}",
))
return PlatformTraceResponse(
input_host=host,
input_path=path,
input_platform_code_body=platform_code_body,
steps=steps,
resolved_platform_code=platform.code if platform else None,
resolved_platform_id=platform.id if platform else None,
resolved_store_code=store.store_code if store else None,
resolved_store_id=store.id if store else None,
login_platform_source=login_source,
login_platform_code=login_platform_code,
)
def _simulate_store_detection(clean_path: str, host: str) -> dict | None:
"""
Simulate StoreContextManager.detect_store_context() for a given path/host.
Reproduces the same logic without needing a real Request object.
"""
from app.core.config import settings
from app.modules.tenancy.models import StoreDomain
host_without_port = host.split(":")[0] if ":" in host else host
# Method 1: Custom domain
platform_domain = getattr(settings, "platform_domain", "platform.com")
is_custom_domain = (
host_without_port
and not host_without_port.endswith(f".{platform_domain}")
and host_without_port != platform_domain
and host_without_port not in ["localhost", "127.0.0.1", "admin.localhost", "admin.127.0.0.1"]
and not host_without_port.startswith("admin.")
)
if is_custom_domain:
normalized_domain = StoreDomain.normalize_domain(host_without_port)
return {
"domain": normalized_domain,
"detection_method": "custom_domain",
"host": host_without_port,
"original_host": host,
}
# Method 2: Subdomain
if "." in host_without_port:
parts = host_without_port.split(".")
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
subdomain = parts[0]
return {
"subdomain": subdomain,
"detection_method": "subdomain",
"host": host_without_port,
}
# Method 3: Path-based
if clean_path.startswith(("/store/", "/stores/", "/storefront/")):
if clean_path.startswith("/storefront/"):
prefix_len = len("/storefront/")
elif clean_path.startswith("/stores/"):
prefix_len = len("/stores/")
else:
prefix_len = len("/store/")
path_parts = clean_path[prefix_len:].split("/")
if len(path_parts) >= 1 and path_parts[0]:
store_code = path_parts[0]
return {
"subdomain": store_code,
"detection_method": "path",
"path_prefix": clean_path[:prefix_len + len(store_code)],
"full_prefix": clean_path[:prefix_len],
"host": host_without_port,
}
return None
def _sanitize(d: dict | None) -> dict | None:
"""Remove non-serializable objects from dict."""
if d is None:
return None
return {k: v for k, v in d.items() if not k.startswith("_")}

View File

@@ -0,0 +1,158 @@
# app/modules/dev_tools/routes/api/admin_sql_query.py
"""
SQL Query API endpoints.
All endpoints require super-admin authentication via Bearer token.
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import UserContext, get_current_super_admin_api, get_db
from app.modules.dev_tools.services.sql_query_service import (
create_saved_query,
delete_saved_query,
execute_query,
list_saved_queries,
record_query_run,
update_saved_query,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sql-query", tags=["sql-query"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ExecuteRequest(BaseModel):
sql: str = Field(..., min_length=1, max_length=10000)
saved_query_id: int | None = None
class SavedQueryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
sql_text: str = Field(..., min_length=1, max_length=10000)
description: str | None = None
class SavedQueryUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
sql_text: str | None = Field(None, min_length=1, max_length=10000)
description: str | None = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/execute") # noqa: API001
async def execute_sql(
body: ExecuteRequest,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Execute a read-only SQL query."""
result = execute_query(db, body.sql)
# Track run if this was a saved query
if body.saved_query_id:
try:
record_query_run(db, body.saved_query_id)
db.commit()
except Exception:
db.rollback()
return result
@router.get("/saved") # noqa: API001
async def get_saved_queries(
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""List all saved queries."""
queries = list_saved_queries(db)
return [
{
"id": q.id,
"name": q.name,
"description": q.description,
"sql_text": q.sql_text,
"created_by": q.created_by,
"last_run_at": q.last_run_at.isoformat() if q.last_run_at else None,
"run_count": q.run_count,
"created_at": q.created_at.isoformat() if q.created_at else None,
}
for q in queries
]
@router.post("/saved") # noqa: API001
async def create_saved(
body: SavedQueryCreate,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Create a new saved query."""
q = create_saved_query(
db,
name=body.name,
sql_text=body.sql_text,
description=body.description,
created_by=ctx.id,
)
db.commit()
return {
"id": q.id,
"name": q.name,
"description": q.description,
"sql_text": q.sql_text,
"created_by": q.created_by,
"run_count": q.run_count,
"created_at": q.created_at.isoformat() if q.created_at else None,
}
@router.put("/saved/{query_id}") # noqa: API001
async def update_saved(
query_id: int,
body: SavedQueryUpdate,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Update a saved query."""
q = update_saved_query(
db,
query_id,
name=body.name,
sql_text=body.sql_text,
description=body.description,
)
db.commit()
return {
"id": q.id,
"name": q.name,
"description": q.description,
"sql_text": q.sql_text,
"run_count": q.run_count,
}
@router.delete("/saved/{query_id}") # noqa: API001
async def delete_saved(
query_id: int,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Delete a saved query."""
delete_saved_query(db, query_id)
db.commit()
return {"ok": True}

View File

@@ -129,3 +129,39 @@ async def admin_test_stores_users_migration(
"dev_tools/admin/test-stores-users-migration.html",
get_admin_context(request, db, current_user),
)
@router.get("/platform-debug", response_class=HTMLResponse, include_in_schema=False)
async def admin_platform_debug_page(
request: Request,
current_user: User = Depends(
require_menu_access("testing", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render platform resolution debug page.
Traces middleware pipeline for all URL patterns.
"""
return templates.TemplateResponse(
"dev_tools/admin/platform-debug.html",
get_admin_context(request, db, current_user),
)
@router.get("/sql-query", response_class=HTMLResponse, include_in_schema=False)
async def admin_sql_query_page(
request: Request,
current_user: User = Depends(
require_menu_access("sql-query", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render SQL query tool page.
Ad-hoc SQL query execution with saved query management.
"""
return templates.TemplateResponse(
"dev_tools/admin/sql-query.html",
get_admin_context(request, db, current_user),
)

View File

@@ -0,0 +1,197 @@
# app/modules/dev_tools/services/sql_query_service.py
"""
SQL Query Service
Provides safe, read-only SQL query execution and saved query CRUD operations.
Security layers:
1. Regex-based DML/DDL rejection
2. SET TRANSACTION READ ONLY on PostgreSQL
3. Statement timeout (30s)
4. Automatic rollback after every execution
"""
import re
import time
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.exceptions.base import ResourceNotFoundException, ValidationException
from app.modules.dev_tools.models.saved_query import SavedQuery
# Forbidden SQL keywords — matches whole words, case-insensitive
_FORBIDDEN_PATTERN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|VACUUM|REINDEX)\b",
re.IGNORECASE,
)
class QueryValidationError(ValidationException):
"""Raised when a query contains forbidden SQL statements."""
def __init__(self, message: str):
super().__init__(message=message, field="sql")
def _strip_sql_comments(sql: str) -> str:
"""Remove SQL comments (-- line comments and /* block comments */)."""
# Remove block comments
result = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
# Remove line comments
result = re.sub(r"--[^\n]*", " ", result)
return result
def validate_query(sql: str) -> None:
"""Validate that the SQL query is SELECT-only (no DML/DDL)."""
stripped = sql.strip().rstrip(";")
if not stripped:
raise QueryValidationError("Query cannot be empty.")
# Strip comments before checking for forbidden keywords
code_only = _strip_sql_comments(stripped)
match = _FORBIDDEN_PATTERN.search(code_only)
if match:
raise QueryValidationError(
f"Forbidden SQL keyword: {match.group().upper()}. Only SELECT queries are allowed."
)
def _make_json_safe(value: Any) -> Any:
"""Convert a database value to a JSON-serializable representation."""
if value is None:
return None
if isinstance(value, int | float | bool):
return value
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, UUID):
return str(value)
if isinstance(value, bytes):
return f"<bytes({len(value)})>"
if isinstance(value, list | dict):
return value
return str(value)
def execute_query(db: Session, sql: str) -> dict:
"""
Execute a read-only SQL query and return results.
Returns:
dict with columns, rows, row_count, truncated, execution_time_ms
"""
validate_query(sql)
max_rows = 1000
connection = db.connection()
try:
# Set read-only transaction and statement timeout
connection.execute(text("SET TRANSACTION READ ONLY"))
connection.execute(text("SET statement_timeout = '30s'"))
start = time.perf_counter()
result = connection.execute(text(sql))
rows_raw = result.fetchmany(max_rows + 1)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
columns = list(result.keys()) if result.returns_rows else []
truncated = len(rows_raw) > max_rows
rows_raw = rows_raw[:max_rows]
rows = [
[_make_json_safe(cell) for cell in row] for row in rows_raw
]
return {
"columns": columns,
"rows": rows,
"row_count": len(rows),
"truncated": truncated,
"execution_time_ms": elapsed_ms,
}
except (QueryValidationError, ValidationException):
raise
except Exception as e:
raise QueryValidationError(str(e)) from e
finally:
db.rollback()
# ---------------------------------------------------------------------------
# Saved Query CRUD
# ---------------------------------------------------------------------------
def list_saved_queries(db: Session) -> list[SavedQuery]:
"""List all saved queries ordered by name."""
return db.query(SavedQuery).order_by(SavedQuery.name).all()
def create_saved_query(
db: Session,
*,
name: str,
sql_text: str,
description: str | None,
created_by: int,
) -> SavedQuery:
"""Create a new saved query."""
query = SavedQuery(
name=name,
sql_text=sql_text,
description=description,
created_by=created_by,
)
db.add(query)
db.flush()
db.refresh(query)
return query
def update_saved_query(
db: Session,
query_id: int,
*,
name: str | None = None,
sql_text: str | None = None,
description: str | None = None,
) -> SavedQuery:
"""Update an existing saved query. Raises ResourceNotFoundException if not found."""
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
if not query:
raise ResourceNotFoundException("SavedQuery", str(query_id))
if name is not None:
query.name = name
if sql_text is not None:
query.sql_text = sql_text
if description is not None:
query.description = description
db.flush()
db.refresh(query)
return query
def delete_saved_query(db: Session, query_id: int) -> None:
"""Delete a saved query. Raises ResourceNotFoundException if not found."""
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
if not query:
raise ResourceNotFoundException("SavedQuery", str(query_id))
db.delete(query)
db.flush()
def record_query_run(db: Session, query_id: int) -> None:
"""Increment run_count and update last_run_at for a saved query."""
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
if query:
query.run_count = (query.run_count or 0) + 1
query.last_run_at = datetime.now(UTC)
db.flush()

View File

@@ -0,0 +1,249 @@
// app/modules/dev_tools/static/admin/js/sql-query.js
const sqlLog = window.LogConfig.createLogger('SQL_QUERY');
/**
* SQL Query Tool Alpine.js Component
* Execute ad-hoc SQL queries and manage saved queries.
*/
function sqlQueryTool() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'sql-query',
// Editor state
sql: '',
running: false,
error: null,
// Results
columns: [],
rows: [],
rowCount: 0,
truncated: false,
executionTimeMs: null,
// Saved queries
savedQueries: [],
loadingSaved: false,
activeSavedId: null,
// Save modal
showSaveModal: false,
saveName: '',
saveDescription: '',
saving: false,
// Schema explorer
showPresets: true,
expandedCategories: {},
presetQueries: [
{
category: 'Schema',
items: [
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
]
},
{
category: 'Statistics',
items: [
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
]
},
{
category: 'Tenancy',
items: [
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
]
},
{
category: 'Permissions',
items: [
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
]
},
{
category: 'System',
items: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
]
},
],
toggleCategory(category) {
this.expandedCategories[category] = !this.expandedCategories[category];
},
isCategoryExpanded(category) {
return this.expandedCategories[category] || false;
},
async init() {
if (window._sqlQueryInitialized) return;
window._sqlQueryInitialized = true;
this.$nextTick(() => {
if (typeof this.initBase === 'function') this.initBase();
});
try {
await this.loadSavedQueries();
} catch (e) {
sqlLog.error('Failed to initialize:', e);
}
// Ctrl+Enter shortcut
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.executeQuery();
}
});
},
async executeQuery() {
if (!this.sql.trim() || this.running) return;
this.running = true;
this.error = null;
this.columns = [];
this.rows = [];
this.rowCount = 0;
this.truncated = false;
this.executionTimeMs = null;
try {
const payload = { sql: this.sql };
if (this.activeSavedId) {
payload.saved_query_id = this.activeSavedId;
}
const data = await apiClient.post('/admin/sql-query/execute', payload);
this.columns = data.columns;
this.rows = data.rows;
this.rowCount = data.row_count;
this.truncated = data.truncated;
this.executionTimeMs = data.execution_time_ms;
// Refresh saved queries to update run_count
if (this.activeSavedId) {
await this.loadSavedQueries();
}
} catch (e) {
this.error = e.message;
} finally {
this.running = false;
}
},
async loadSavedQueries() {
this.loadingSaved = true;
try {
this.savedQueries = await apiClient.get('/admin/sql-query/saved');
} catch (e) {
sqlLog.error('Failed to load saved queries:', e);
} finally {
this.loadingSaved = false;
}
},
loadQuery(q) {
this.sql = q.sql_text;
this.activeSavedId = q.id;
this.error = null;
},
loadPreset(preset) {
this.sql = preset.sql;
this.activeSavedId = null;
this.error = null;
},
openSaveModal() {
this.saveName = '';
this.saveDescription = '';
this.showSaveModal = true;
},
async saveQuery() {
if (!this.saveName.trim() || !this.sql.trim()) return;
this.saving = true;
try {
const saved = await apiClient.post('/admin/sql-query/saved', {
name: this.saveName,
sql_text: this.sql,
description: this.saveDescription || null,
});
this.activeSavedId = saved.id;
this.showSaveModal = false;
await this.loadSavedQueries();
} catch (e) {
this.error = e.message;
} finally {
this.saving = false;
}
},
async deleteSavedQuery(id) {
if (!confirm('Delete this saved query?')) return;
try {
await apiClient.delete(`/admin/sql-query/saved/${id}`);
if (this.activeSavedId === id) {
this.activeSavedId = null;
}
await this.loadSavedQueries();
} catch (e) {
sqlLog.error('Failed to delete:', e);
}
},
exportCsv() {
if (!this.columns.length || !this.rows.length) return;
const escape = (val) => {
if (val === null || val === undefined) return '';
const s = String(val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
const lines = [this.columns.map(escape).join(',')];
for (const row of this.rows) {
lines.push(row.map(escape).join(','));
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'query-results.csv';
a.click();
URL.revokeObjectURL(url);
},
formatCell(val) {
if (val === null || val === undefined) return 'NULL';
return String(val);
},
isNull(val) {
return val === null || val === undefined;
},
};
}

View File

@@ -0,0 +1,466 @@
{# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #}
{% extends "admin/base.html" %}
{% block title %}Platform Resolution Debug{% endblock %}
{% block alpine_data %}platformDebug(){% endblock %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Simulates the middleware pipeline for each URL pattern to trace how platform &amp; store context are resolved.
</p>
</div>
<!-- Controls -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<div class="flex items-center gap-4 flex-wrap">
<button @click="runAllTests()" :disabled="running"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
<span x-show="!running">Run All Tests</span>
<span x-show="running">Running...</span>
</button>
<div class="flex gap-2">
<button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button>
<button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button>
<button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button>
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button>
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button>
</div>
</div>
</div>
<!-- Custom test -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3>
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
<input x-model="customHost" type="text" placeholder="localhost:8000"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
</div>
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
Trace
</button>
</div>
<!-- Custom result -->
<template x-if="customResult">
<div class="mt-4">
<div x-html="renderTrace(customResult)"></div>
</div>
</template>
</div>
<!-- Test Results -->
<div class="space-y-4">
<template x-for="test in filteredTests" :key="test.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
<!-- Header -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between"
@click="test.expanded = !test.expanded">
<div class="flex items-center gap-3">
<!-- Status indicator -->
<div class="w-3 h-3 rounded-full"
:class="{
'bg-gray-300 dark:bg-gray-600': !test.result,
'bg-green-500': test.result && test.pass,
'bg-red-500': test.result && !test.pass,
'animate-pulse bg-yellow-400': test.running
}"></div>
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': test.group === 'dev',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain',
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom'
}" x-text="test.groupLabel"></span>
<div>
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span>
</div>
</div>
<div class="flex items-center gap-3">
<template x-if="test.result">
<span class="text-xs font-mono px-2 py-1 rounded"
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
</template>
<template x-if="test.result">
<button @click.stop="copyTrace(test)" title="Copy full trace"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Copy
</button>
</template>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Detail -->
<div x-show="test.expanded" x-cloak class="px-4 py-3">
<template x-if="test.result">
<div x-html="renderTrace(test.result)"></div>
</template>
<template x-if="!test.result && !test.running">
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
</template>
<template x-if="test.running">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p>
</template>
</div>
</div>
</template>
</div>
<script>
function platformDebug() {
return {
running: false,
filterGroup: 'all',
customHost: 'localhost:8000',
customPath: '/platforms/loyalty/store/WIZATECH/login',
customPlatformCode: '',
customResult: null,
tests: [
// ── Dev path-based ──
{
id: 'dev-1', group: 'dev', groupLabel: 'Dev',
label: '/platforms/oms/store/ACME/login',
description: 'Store login, OMS platform context',
host: 'localhost:8000',
path: '/platforms/oms/store/ACME/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-2', group: 'dev', groupLabel: 'Dev',
label: '/platforms/loyalty/store/ACME/login',
description: 'Store login, Loyalty platform context',
host: 'localhost:8000',
path: '/platforms/loyalty/store/ACME/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-3', group: 'dev', groupLabel: 'Dev',
label: 'API: /api/v1/store/auth/login (no body platform)',
description: 'What middleware sees for the login API call — no platform_code in body',
host: 'localhost:8000',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: null,
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-4', group: 'dev', groupLabel: 'Dev',
label: 'API: /api/v1/store/auth/login + body platform_code=loyalty',
description: 'Login API with loyalty in body (Source 2)',
host: 'localhost:8000',
path: '/api/v1/store/auth/login',
platform_code_body: 'loyalty',
store_code_body: 'WIZATECH',
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-5', group: 'dev', groupLabel: 'Dev',
label: 'API: /api/v1/store/auth/login + body platform_code=oms',
description: 'Login API with oms in body (Source 2)',
host: 'localhost:8000',
path: '/api/v1/store/auth/login',
platform_code_body: 'oms',
store_code_body: 'WIZATECH',
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-6', group: 'dev', groupLabel: 'Dev',
label: '/store/ACME/login (no /platforms/ prefix)',
description: 'Store login without platform prefix on localhost',
host: 'localhost:8000',
path: '/store/ACME/login',
platform_code_body: null,
expect_platform: null,
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-7', group: 'dev', groupLabel: 'Dev',
label: '/platforms/oms/storefront/ACME/account/login',
description: 'Customer login, OMS platform context',
host: 'localhost:8000',
path: '/platforms/oms/storefront/ACME/account/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-8', group: 'dev', groupLabel: 'Dev',
label: '/platforms/loyalty/storefront/ACME/account/login',
description: 'Customer login, Loyalty platform context',
host: 'localhost:8000',
path: '/platforms/loyalty/storefront/ACME/account/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
// ── Prod domain-based (path-based demo/trial) ──
{
id: 'prod-d-1', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'omsflow.lu /store/ACME/login',
description: 'OMS platform domain, store login',
host: 'omsflow.lu',
path: '/store/ACME/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-d-2', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'rewardflow.lu /store/ACME/login',
description: 'Loyalty platform domain, store login',
host: 'rewardflow.lu',
path: '/store/ACME/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-d-3', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'omsflow.lu /api/v1/store/auth/login (API)',
description: 'API call on OMS domain — middleware should detect platform',
host: 'omsflow.lu',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-d-4', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'omsflow.lu /storefront/ACME/account/login',
description: 'Customer login on OMS domain',
host: 'omsflow.lu',
path: '/storefront/ACME/account/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
// ── Prod subdomain ──
{
id: 'prod-s-1', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme.omsflow.lu /store/login',
description: 'OMS subdomain, store login',
host: 'acme.omsflow.lu',
path: '/store/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-s-2', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme.omsflow.lu /account/login',
description: 'OMS subdomain, customer login',
host: 'acme.omsflow.lu',
path: '/account/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-s-3', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme-rewards.rewardflow.lu /store/login',
description: 'Loyalty subdomain (custom_subdomain), store login',
host: 'acme-rewards.rewardflow.lu',
path: '/store/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-s-4', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme.omsflow.lu /api/v1/store/auth/login (API)',
description: 'API call on OMS subdomain',
host: 'acme.omsflow.lu',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
// ── Prod custom domain ──
// Uses real StoreDomain records:
// wizatech.shop → store_id=1 (WIZATECH), platform_id=1 (oms)
// fashionhub.store → store_id=4 (FASHIONHUB), platform_id=3 (loyalty)
{
id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'wizatech.shop /store/login',
description: 'Custom domain → WIZATECH, OMS platform (StoreDomain.platform_id=1)',
host: 'wizatech.shop',
path: '/store/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-c-2', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'fashionhub.store /store/login',
description: 'Custom domain → FASHIONHUB, Loyalty platform (StoreDomain.platform_id=3)',
host: 'fashionhub.store',
path: '/store/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-c-3', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'wizatech.shop /api/v1/store/auth/login (API)',
description: 'API call on custom domain — middleware should detect platform',
host: 'wizatech.shop',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-c-4', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'fashionhub.store /api/v1/store/auth/login (API)',
description: 'API call on custom domain — middleware should detect platform',
host: 'fashionhub.store',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
],
get filteredTests() {
if (this.filterGroup === 'all') return this.tests;
return this.tests.filter(t => t.group === this.filterGroup);
},
async runAllTests() {
this.running = true;
for (const test of this.tests) {
test.running = true;
try {
test.result = await this.traceRequest(test.host, test.path, test.platform_code_body, test.store_code_body);
test.pass = test.expect_platform === null
? true // No expectation
: test.result.login_platform_code === test.expect_platform;
} catch (e) {
test.result = { error: e.message };
test.pass = false;
}
test.running = false;
}
this.running = false;
},
async runCustomTest() {
try {
this.customResult = await this.traceRequest(
this.customHost, this.customPath, this.customPlatformCode || null
);
} catch (e) {
this.customResult = { error: e.message };
}
},
async traceRequest(host, path, platformCodeBody, storeCodeBody) {
let url = `/api/v1/admin/debug/platform-trace?host=${encodeURIComponent(host)}&path=${encodeURIComponent(path)}`;
if (platformCodeBody) url += `&platform_code_body=${encodeURIComponent(platformCodeBody)}`;
if (storeCodeBody) url += `&store_code_body=${encodeURIComponent(storeCodeBody)}`;
const resp = await apiClient.get(url.replace('/api/v1', ''));
return resp;
},
copyTrace(test) {
const r = test.result;
if (!r || r.error) return;
let text = `${test.label}\n${test.description}\n`;
text += `Host: ${r.input_host} Path: ${r.input_path}`;
if (r.input_platform_code_body) text += ` Body platform_code: ${r.input_platform_code_body}`;
text += '\n\n';
for (const step of r.steps) {
text += `${step.step}\n`;
if (step.note) text += `${step.note}\n`;
if (step.result) text += JSON.stringify(step.result, null, 2) + '\n';
text += '\n';
}
navigator.clipboard.writeText(text);
},
copyStepText(step) {
let text = `${step.step}\n`;
if (step.note) text += `${step.note}\n`;
if (step.result) text += JSON.stringify(step.result, null, 2);
navigator.clipboard.writeText(text);
},
renderTrace(result) {
if (result.error) {
return `<div class="text-red-600 dark:text-red-400 text-sm font-mono">${result.error}</div>`;
}
let html = `<div class="text-xs font-mono space-y-1">`;
html += `<div class="mb-2 text-gray-500 dark:text-gray-400">`;
html += `Host: <span class="text-gray-900 dark:text-white">${result.input_host}</span> `;
html += `Path: <span class="text-gray-900 dark:text-white">${result.input_path}</span>`;
if (result.input_platform_code_body) {
html += ` Body platform_code: <span class="text-gray-900 dark:text-white">${result.input_platform_code_body}</span>`;
}
html += `</div>`;
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
const isLast = step.step.startsWith('8.');
const bgClass = isLast
? 'bg-indigo-50 dark:bg-indigo-900/30 border-indigo-200 dark:border-indigo-700'
: 'bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700';
html += `<div class="p-2 rounded border ${bgClass}">`;
html += `<div class="flex items-start justify-between">`;
html += `<div class="font-semibold text-gray-700 dark:text-gray-300">${step.step}</div>`;
const stepData = btoa(unescape(encodeURIComponent(
step.step + '\n' + (step.note || '') + '\n' + (step.result ? JSON.stringify(step.result, null, 2) : '')
)));
html += `<button onclick="navigator.clipboard.writeText(decodeURIComponent(escape(atob(this.dataset.text))))" data-text="${stepData}" class="ml-2 shrink-0 px-1.5 py-0.5 text-[10px] bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Copy</button>`;
html += `</div>`;
if (step.note) {
html += `<div class="text-gray-500 dark:text-gray-400 mt-0.5">${step.note}</div>`;
}
if (step.result) {
const jsonStr = JSON.stringify(step.result, null, 2);
html += `<pre class="mt-1 text-green-700 dark:text-green-400 whitespace-pre-wrap">${jsonStr}</pre>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,205 @@
{# app/modules/dev_tools/templates/dev_tools/admin/sql-query.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}SQL Query Tool{% endblock %}
{% block alpine_data %}sqlQueryTool(){% endblock %}
{% block content %}
{{ page_header('SQL Query Tool', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
<div class="flex gap-6">
<!-- Left sidebar -->
<div class="w-72 flex-shrink-0 space-y-4">
<!-- Schema Explorer (preset queries) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<button @click="showPresets = !showPresets"
class="flex items-center justify-between w-full text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
<span class="flex items-center gap-1.5">
<span x-html="$icon('database', 'w-4 h-4')"></span>
Schema Explorer
</span>
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
</button>
<div x-show="showPresets" x-collapse class="mt-3">
<template x-for="group in presetQueries" :key="group.category">
<div class="mb-1">
<button @click="toggleCategory(group.category)"
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<span x-text="group.category"></span>
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
</button>
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
<template x-for="preset in group.items" :key="preset.name">
<li @click="loadPreset(preset)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
<span class="truncate" x-text="preset.name"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</div>
<!-- Saved Queries -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<span x-html="$icon('collection', 'w-4 h-4')"></span>
Saved Queries
</h3>
<div x-show="loadingSaved" class="text-sm text-gray-500">Loading...</div>
<div x-show="!loadingSaved && savedQueries.length === 0" class="text-sm text-gray-400">
No saved queries yet.
</div>
<ul class="space-y-1">
<template x-for="q in savedQueries" :key="q.id">
<li class="group flex items-center justify-between rounded-md px-2 py-1.5 text-sm cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
:class="activeSavedId === q.id ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'"
@click="loadQuery(q)">
<div class="flex-1 min-w-0">
<div class="truncate font-medium" x-text="q.name"></div>
<div class="text-xs text-gray-400" x-show="q.run_count > 0">
Run <span x-text="q.run_count"></span> time<span x-show="q.run_count !== 1">s</span>
</div>
</div>
<button @click.stop="deleteSavedQuery(q.id)"
class="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500 transition-opacity"
title="Delete">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</li>
</template>
</ul>
</div>
</div>
<!-- Main area -->
<div class="flex-1 min-w-0">
<!-- SQL Editor -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-4">
<div class="p-4">
<textarea
x-model="sql"
rows="8"
class="w-full bg-gray-900 text-green-400 font-mono text-sm rounded-lg p-4 border border-gray-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 resize-y"
placeholder="Enter your SQL query here... (SELECT only)"
spellcheck="false"
></textarea>
</div>
<!-- Action bar -->
<div class="flex items-center gap-3 px-4 pb-4">
<button @click="executeQuery()"
:disabled="running || !sql.trim()"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="!running" x-html="$icon('play', 'w-4 h-4 mr-1.5')"></span>
<span x-show="running" x-html="$icon('spinner', 'w-4 h-4 mr-1.5')"></span>
<span x-text="running ? 'Running...' : 'Run Query'"></span>
<span class="ml-1.5 text-xs opacity-70">(Ctrl+Enter)</span>
</button>
<button @click="openSaveModal()"
:disabled="!sql.trim()"
class="inline-flex items-center px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-html="$icon('save', 'w-4 h-4 mr-1.5')"></span>
Save Query
</button>
<button @click="exportCsv()"
x-show="rows.length > 0"
class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
<span x-html="$icon('download', 'w-4 h-4 mr-1.5')"></span>
Export CSV
</button>
</div>
</div>
<!-- Execution info -->
<div x-show="executionTimeMs !== null" class="mb-4 text-sm text-gray-600 dark:text-gray-400 flex items-center gap-4">
<span>
<span x-text="rowCount"></span> row<span x-show="rowCount !== 1">s</span> returned
</span>
<span x-show="truncated" class="text-amber-600 dark:text-amber-400 font-medium">
(results truncated to 1000 rows)
</span>
<span>
<span x-text="executionTimeMs"></span> ms
</span>
</div>
<!-- Error display -->
<div x-show="error" class="mb-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex items-start">
<span x-html="$icon('exclamation-circle', 'w-5 h-5 text-red-500 mr-2 flex-shrink-0')"></span>
<pre class="text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words" x-text="error"></pre>
</div>
</div>
<!-- Results table -->
<div x-show="columns.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<template x-for="col in columns" :key="col">
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap"
x-text="col"></th>
</template>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="(row, rowIdx) in rows" :key="rowIdx">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750">
<template x-for="(cell, cellIdx) in row" :key="cellIdx">
<td class="px-4 py-2 text-sm font-mono whitespace-nowrap max-w-xs truncate"
:class="isNull(cell) ? 'text-gray-400 italic' : 'text-gray-900 dark:text-gray-100'"
:title="formatCell(cell)"
x-text="formatCell(cell)"></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Save Query Modal -->
{% call modal('saveQueryModal', 'Save Query', show_var='showSaveModal', size='sm', show_footer=false) %}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input type="text" x-model="saveName"
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g. Active users count"
@keydown.enter="saveQuery()">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description (optional)</label>
<input type="text" x-model="saveDescription"
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Brief description of what this query does">
</div>
<div class="flex justify-end gap-3 pt-2">
<button @click="showSaveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button @click="saveQuery()"
:disabled="!saveName.trim() || saving"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
<span x-text="saving ? 'Saving...' : 'Save'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('dev_tools_static', path='admin/js/sql-query.js') }}"></script>
{% endblock %}

View File

@@ -129,9 +129,9 @@ loyalty_module = ModuleDefinition(
"loyalty-analytics", # Platform-wide stats
],
FrontendType.STORE: [
"loyalty", # Loyalty dashboard
"loyalty-cards", # Customer cards
"loyalty-stats", # Store stats
"terminal", # Loyalty terminal
"cards", # Customer cards
"stats", # Store stats
],
FrontendType.MERCHANT: [
"loyalty-overview", # Merchant loyalty overview

View File

@@ -6,6 +6,8 @@ Admin-controlled settings that apply to a merchant's loyalty program.
These settings are managed by platform administrators, not stores.
"""
import enum
from sqlalchemy import (
Boolean,
Column,
@@ -20,7 +22,7 @@ from app.core.database import Base
from models.database.base import TimestampMixin
class StaffPinPolicy(str):
class StaffPinPolicy(str, enum.Enum):
"""Staff PIN policy options."""
REQUIRED = "required" # Staff PIN always required

View File

@@ -11,6 +11,7 @@ Platform endpoints for:
import logging
from fastapi import APIRouter, Depends, Header, Path, Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -92,8 +93,14 @@ def download_apple_pass(
# =============================================================================
class AppleRegisterDeviceRequest(BaseModel):
"""Request body for Apple device registration."""
push_token: str = Field(..., alias="pushToken")
@platform_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
def register_device(
body: AppleRegisterDeviceRequest,
device_id: str = Path(...),
pass_type_id: str = Path(...),
serial_number: str = Path(...),
@@ -111,10 +118,7 @@ def register_device(
# Verify auth token (raises InvalidAppleAuthTokenException if invalid)
apple_wallet_service.verify_auth_token(card, authorization)
# Get push token from request body
# Note: In real implementation, parse the JSON body for pushToken
# For now, use device_id as a placeholder
apple_wallet_service.register_device_safe(db, card, device_id, device_id)
apple_wallet_service.register_device_safe(db, card, device_id, body.pushToken)
return Response(status_code=201)

View File

@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api, require_module_access
from app.core.database import get_db
from app.exceptions.base import AuthorizationException
from app.modules.enums import FrontendType
from app.modules.loyalty.schemas import (
CardDetailResponse,
@@ -40,8 +41,10 @@ from app.modules.loyalty.schemas import (
PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
ProgramCreate,
ProgramResponse,
ProgramStatsResponse,
ProgramUpdate,
StampRedeemRequest,
StampRedeemResponse,
StampRequest,
@@ -104,6 +107,52 @@ def get_program(
return response
@router.post("/program", response_model=ProgramResponse, status_code=201)
def create_program(
data: ProgramCreate,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Create a loyalty program (merchant_owner only)."""
if current_user.role != "merchant_owner":
raise AuthorizationException("Only merchant owners can create programs")
store_id = current_user.token_store_id
merchant_id = get_store_merchant_id(db, store_id)
program = program_service.create_program(db, merchant_id, data)
response = ProgramResponse.model_validate(program)
response.is_stamps_enabled = program.is_stamps_enabled
response.is_points_enabled = program.is_points_enabled
response.display_name = program.display_name
return response
@router.put("/program", response_model=ProgramResponse)
def update_program(
data: ProgramUpdate,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Update the merchant's loyalty program (merchant_owner only)."""
if current_user.role != "merchant_owner":
raise AuthorizationException("Only merchant owners can update programs")
store_id = current_user.token_store_id
program = program_service.require_program_by_store(db, store_id)
program = program_service.update_program(db, program.id, data)
response = ProgramResponse.model_validate(program)
response.is_stamps_enabled = program.is_stamps_enabled
response.is_points_enabled = program.is_points_enabled
response.display_name = program.display_name
return response
@router.get("/stats", response_model=ProgramStatsResponse)
def get_stats(
current_user: User = Depends(get_current_store_api),

View File

@@ -208,6 +208,32 @@ async def store_loyalty_stats(
)
# ============================================================================
# SETTINGS (Merchant Owner)
# ============================================================================
@router.get(
"/loyalty/settings",
response_class=HTMLResponse,
include_in_schema=False,
)
async def store_loyalty_settings(
request: Request,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render loyalty program settings page.
Allows merchant owners to create or edit their loyalty program.
"""
return templates.TemplateResponse(
"loyalty/store/settings.html",
get_store_context(request, db, current_user, store_code),
)
# ============================================================================
# ENROLLMENT
# ============================================================================

View File

@@ -240,6 +240,7 @@ class ProgramStatsResponse(BaseModel):
# Cards
total_cards: int = 0
active_cards: int = 0
new_this_month: int = 0
# Stamps (if enabled)
total_stamps_issued: int = 0
@@ -250,6 +251,7 @@ class ProgramStatsResponse(BaseModel):
# Points (if enabled)
total_points_issued: int = 0
total_points_redeemed: int = 0
total_points_balance: int = 0
points_this_month: int = 0
points_redeemed_this_month: int = 0
@@ -257,6 +259,12 @@ class ProgramStatsResponse(BaseModel):
cards_with_activity_30d: int = 0
average_stamps_per_card: float = 0.0
average_points_per_card: float = 0.0
avg_points_per_member: float = 0.0
# 30-day metrics
transactions_30d: int = 0
points_issued_30d: int = 0
points_redeemed_30d: int = 0
# Value
estimated_liability_cents: int = 0 # Unredeemed stamps/points value

View File

@@ -234,12 +234,11 @@ class AppleWalletService:
"pass.json": json.dumps(pass_data).encode("utf-8"),
}
# Add placeholder images (in production, these would be actual images)
# For now, we'll skip images and use the pass.json only
# pass_files["icon.png"] = self._get_icon_bytes(program)
# pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
# pass_files["logo.png"] = self._get_logo_bytes(program)
# pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
# Add pass images (icon and logo)
pass_files["icon.png"] = self._get_icon_bytes(program)
pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
pass_files["logo.png"] = self._get_logo_bytes(program)
pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
# Create manifest
manifest = {}
@@ -377,6 +376,105 @@ class AppleWalletService:
return pass_data
def _get_icon_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
"""
Generate icon image for Apple Wallet pass.
Apple icon dimensions: 29x29 (@1x), 58x58 (@2x).
Uses program logo if available, otherwise generates a colored square
with the program initial.
"""
from PIL import Image, ImageDraw, ImageFont
size = 29 * scale
if program.logo_url:
try:
import httpx
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
img = img.convert("RGBA")
img = img.resize((size, size), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
except Exception:
logger.warning("Failed to fetch logo for icon, using fallback")
# Fallback: colored square with initial
hex_color = (program.card_color or "#4F46E5").lstrip("#")
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
img = Image.new("RGBA", (size, size), (r, g, b, 255))
draw = ImageDraw.Draw(img)
initial = (program.display_name or "L")[0].upper()
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size // 2)
except OSError:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), initial, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((size - tw) / 2, (size - th) / 2 - bbox[1]), initial, fill=(255, 255, 255, 255), font=font)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _get_logo_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
"""
Generate logo image for Apple Wallet pass.
Apple logo dimensions: 160x50 (@1x), 320x100 (@2x).
Uses program logo if available, otherwise generates a colored rectangle
with the program initial.
"""
from PIL import Image, ImageDraw, ImageFont
width, height = 160 * scale, 50 * scale
if program.logo_url:
try:
import httpx
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
img = img.convert("RGBA")
# Fit within dimensions preserving aspect ratio
img.thumbnail((width, height), Image.LANCZOS)
# Center on transparent canvas
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
x = (width - img.width) // 2
y = (height - img.height) // 2
canvas.paste(img, (x, y))
buf = io.BytesIO()
canvas.save(buf, format="PNG")
return buf.getvalue()
except Exception:
logger.warning("Failed to fetch logo for pass logo, using fallback")
# Fallback: colored rectangle with initial
hex_color = (program.card_color or "#4F46E5").lstrip("#")
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
initial = (program.display_name or "L")[0].upper()
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", height // 2)
except OSError:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), initial, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
# Draw initial centered
draw.text(((width - tw) / 2, (height - th) / 2 - bbox[1]), initial, fill=(r, g, b, 255), font=font)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _hex_to_rgb(self, hex_color: str) -> str:
"""Convert hex color to RGB format for Apple Wallet."""
hex_color = hex_color.lstrip("#")

View File

@@ -164,7 +164,34 @@ class LoyaltyFeatureProvider:
db: Session,
store_id: int,
) -> list[FeatureUsage]:
return []
from sqlalchemy import func
from app.modules.loyalty.models import LoyaltyCard, StaffPin
active_cards = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.enrolled_at_store_id == store_id,
LoyaltyCard.is_active == True,
)
.scalar()
or 0
)
active_pins = (
db.query(func.count(StaffPin.id))
.filter(
StaffPin.store_id == store_id,
StaffPin.is_active == True,
)
.scalar()
or 0
)
return [
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
]
def get_merchant_usage(
self,
@@ -172,7 +199,58 @@ class LoyaltyFeatureProvider:
merchant_id: int,
platform_id: int,
) -> list[FeatureUsage]:
return []
from sqlalchemy import func
from app.modules.loyalty.models import (
AppleDeviceRegistration,
LoyaltyCard,
StaffPin,
)
active_cards = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.merchant_id == merchant_id,
LoyaltyCard.is_active == True,
)
.scalar()
or 0
)
active_pins = (
db.query(func.count(StaffPin.id))
.filter(
StaffPin.merchant_id == merchant_id,
StaffPin.is_active == True,
)
.scalar()
or 0
)
apple_registrations = (
db.query(func.count(AppleDeviceRegistration.id))
.join(LoyaltyCard)
.filter(LoyaltyCard.merchant_id == merchant_id)
.scalar()
or 0
)
google_wallets = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.merchant_id == merchant_id,
LoyaltyCard.google_object_id.isnot(None),
)
.scalar()
or 0
)
return [
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
FeatureUsage(feature_code="loyalty_apple_wallet", current_usage=apple_registrations),
FeatureUsage(feature_code="loyalty_google_wallet", current_usage=google_wallets),
]
# Singleton instance for module registration

View File

@@ -29,7 +29,7 @@ class LoyaltyOnboardingProvider:
title_key="loyalty.onboarding.create_program.title",
description_key="loyalty.onboarding.create_program.description",
icon="gift",
route_template="/store/{store_code}/loyalty/programs",
route_template="/store/{store_code}/loyalty/settings",
order=300,
category="loyalty",
),
@@ -49,13 +49,13 @@ class LoyaltyOnboardingProvider:
if not store:
return False
count = (
exists = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.merchant_id == store.merchant_id)
.limit(1)
.count()
.first()
is not None
)
return count > 0
return exists
loyalty_onboarding_provider = LoyaltyOnboardingProvider()

View File

@@ -291,9 +291,8 @@ class PinService:
return pin
# No match found - record failed attempt on all unlocked PINs
# This is a simplified approach; in production you might want to
# track which PIN was attempted based on additional context
# No match found - record failed attempt on the first unlocked PIN only
# This limits blast radius to 1 lockout instead of N
locked_pin = None
remaining = None
@@ -306,7 +305,8 @@ class PinService:
if is_now_locked:
locked_pin = pin
else:
remaining = pin.remaining_attempts
remaining = max(0, config.pin_max_failed_attempts - pin.failed_attempts)
break # Only record on the first unlocked PIN
db.commit()

View File

@@ -663,6 +663,78 @@ class ProgramService:
avg_stamps = total_stamps_issued / total_cards if total_cards > 0 else 0
avg_points = total_points_issued / total_cards if total_cards > 0 else 0
# New this month (cards created since month start)
new_this_month = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.program_id == program_id,
LoyaltyCard.created_at >= month_start,
)
.scalar()
or 0
)
# Points activity this month
points_this_month = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.join(LoyaltyCard)
.filter(
LoyaltyCard.program_id == program_id,
LoyaltyTransaction.points_delta > 0,
LoyaltyTransaction.transaction_at >= month_start,
)
.scalar()
or 0
)
points_redeemed_this_month = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.join(LoyaltyCard)
.filter(
LoyaltyCard.program_id == program_id,
LoyaltyTransaction.points_delta < 0,
LoyaltyTransaction.transaction_at >= month_start,
)
.scalar()
or 0
)
# 30-day transaction metrics
transactions_30d = (
db.query(func.count(LoyaltyTransaction.id))
.join(LoyaltyCard)
.filter(
LoyaltyCard.program_id == program_id,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
points_issued_30d = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.join(LoyaltyCard)
.filter(
LoyaltyCard.program_id == program_id,
LoyaltyTransaction.points_delta > 0,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
points_redeemed_30d = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.join(LoyaltyCard)
.filter(
LoyaltyCard.program_id == program_id,
LoyaltyTransaction.points_delta < 0,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
# Estimated liability (unredeemed value)
current_stamps = (
db.query(func.sum(LoyaltyCard.stamp_count))
@@ -677,6 +749,7 @@ class ProgramService:
.scalar()
or 0
)
total_points_balance = current_points
# Rough estimate: assume 100 points = €1
points_value_cents = current_points // 100 * 100
@@ -684,18 +757,28 @@ class ProgramService:
(current_stamps * stamp_value // program.stamps_target) + points_value_cents
)
avg_points_per_member = round(current_points / active_cards, 2) if active_cards > 0 else 0
return {
"total_cards": total_cards,
"active_cards": active_cards,
"new_this_month": new_this_month,
"total_stamps_issued": total_stamps_issued,
"total_stamps_redeemed": total_stamps_redeemed,
"stamps_this_month": stamps_this_month,
"redemptions_this_month": redemptions_this_month,
"total_points_issued": total_points_issued,
"total_points_redeemed": total_points_redeemed,
"total_points_balance": total_points_balance,
"points_this_month": points_this_month,
"points_redeemed_this_month": points_redeemed_this_month,
"cards_with_activity_30d": cards_with_activity_30d,
"average_stamps_per_card": round(avg_stamps, 2),
"average_points_per_card": round(avg_points, 2),
"avg_points_per_member": avg_points_per_member,
"transactions_30d": transactions_30d,
"points_issued_30d": points_issued_30d,
"points_redeemed_30d": points_redeemed_30d,
"estimated_liability_cents": estimated_liability,
}

View File

@@ -59,11 +59,10 @@ function storeLoyaltyCards() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadProgram(),
this.loadCards(),
this.loadStats()
]);
await this.loadProgram();
if (this.program) {
await Promise.all([this.loadCards(), this.loadStats()]);
}
} catch (error) {
loyaltyCardsLog.error('Failed to load data:', error);
this.error = error.message;

View File

@@ -0,0 +1,182 @@
// app/modules/loyalty/static/store/js/loyalty-settings.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings');
// ============================================
// STORE LOYALTY SETTINGS FUNCTION
// ============================================
function loyaltySettings() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'loyalty-settings',
// State
program: null,
loading: false,
saving: false,
error: null,
isOwner: false,
// Form data
form: {
loyalty_type: 'points',
stamps_target: 10,
stamps_reward_description: 'Free item',
stamps_reward_value_cents: null,
points_per_euro: 10,
welcome_bonus_points: 0,
minimum_redemption_points: 100,
minimum_purchase_cents: 0,
points_expiration_days: null,
points_rewards: [],
cooldown_minutes: 15,
max_daily_stamps: 5,
require_staff_pin: true,
card_name: '',
card_color: '#4F46E5',
logo_url: '',
terms_text: '',
},
// Initialize
async init() {
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZING ===');
if (window._loyaltySettingsInitialized) {
loyaltySettingsLog.warn('Already initialized, skipping...');
return;
}
window._loyaltySettingsInitialized = true;
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Check if user is merchant_owner
this.isOwner = this.currentUser?.role === 'merchant_owner';
await this.loadData();
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await this.loadProgram();
} catch (error) {
loyaltySettingsLog.error('Failed to load data:', error);
this.error = error.message || 'Failed to load settings';
} finally {
this.loading = false;
}
},
async loadProgram() {
try {
loyaltySettingsLog.info('Loading program...');
const response = await apiClient.get('/store/loyalty/program');
if (response) {
this.program = response;
this.populateForm(response);
loyaltySettingsLog.info('Program loaded:', response.display_name);
}
} catch (error) {
if (error.status === 404) {
loyaltySettingsLog.info('No program configured — showing create form');
this.program = null;
} else {
throw error;
}
}
},
populateForm(program) {
this.form.loyalty_type = program.loyalty_type || 'points';
this.form.stamps_target = program.stamps_target || 10;
this.form.stamps_reward_description = program.stamps_reward_description || 'Free item';
this.form.stamps_reward_value_cents = program.stamps_reward_value_cents || null;
this.form.points_per_euro = program.points_per_euro || 10;
this.form.welcome_bonus_points = program.welcome_bonus_points || 0;
this.form.minimum_redemption_points = program.minimum_redemption_points || 100;
this.form.minimum_purchase_cents = program.minimum_purchase_cents || 0;
this.form.points_expiration_days = program.points_expiration_days || null;
this.form.points_rewards = (program.points_rewards || []).map(r => ({
id: r.id,
name: r.name,
points_required: r.points_required,
description: r.description || '',
is_active: r.is_active !== false,
}));
this.form.cooldown_minutes = program.cooldown_minutes ?? 15;
this.form.max_daily_stamps = program.max_daily_stamps || 5;
this.form.require_staff_pin = program.require_staff_pin !== false;
this.form.card_name = program.card_name || '';
this.form.card_color = program.card_color || '#4F46E5';
this.form.logo_url = program.logo_url || '';
this.form.terms_text = program.terms_text || '';
},
addReward() {
const id = 'reward_' + Date.now();
this.form.points_rewards.push({
id: id,
name: '',
points_required: 100,
description: '',
is_active: true,
});
},
async saveProgram() {
this.saving = true;
try {
const payload = { ...this.form };
// Clean up empty optional fields
if (!payload.stamps_reward_value_cents) payload.stamps_reward_value_cents = null;
if (!payload.points_expiration_days) payload.points_expiration_days = null;
if (!payload.card_name) payload.card_name = null;
if (!payload.logo_url) payload.logo_url = null;
if (!payload.terms_text) payload.terms_text = null;
let response;
if (this.program) {
// Update existing
response = await apiClient.put('/store/loyalty/program', payload);
Utils.showToast('Program updated successfully', 'success');
} else {
// Create new
response = await apiClient.post('/store/loyalty/program', payload);
Utils.showToast('Program created successfully', 'success');
}
this.program = response;
this.populateForm(response);
loyaltySettingsLog.info('Program saved:', response.display_name);
} catch (error) {
Utils.showToast(`Failed to save: ${error.message}`, 'error');
loyaltySettingsLog.error('Save failed:', error);
} finally {
this.saving = false;
}
},
};
}
// Register logger
if (!window.LogConfig.loggers.loyaltySettings) {
window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings');
}
loyaltySettingsLog.info('Loyalty settings module loaded');

View File

@@ -8,6 +8,8 @@ function storeLoyaltyStats() {
...data(),
currentPage: 'loyalty-stats',
program: null,
stats: {
total_cards: 0,
active_cards: 0,
@@ -35,10 +37,22 @@ function storeLoyaltyStats() {
await parentInit.call(this);
}
await this.loadProgram();
if (this.program) {
await this.loadStats();
}
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ===');
},
async loadProgram() {
try {
const response = await apiClient.get('/store/loyalty/program');
if (response) this.program = response;
} catch (error) {
if (error.status !== 404) throw error;
}
},
async loadStats() {
this.loading = true;
this.error = null;

View File

@@ -191,7 +191,11 @@ function storeLoyaltyTerminal() {
this.processing = true;
try {
if (this.pendingAction === 'earn') {
if (this.pendingAction === 'stamp') {
await this.addStamp();
} else if (this.pendingAction === 'redeemStamps') {
await this.redeemStamps();
} else if (this.pendingAction === 'earn') {
await this.earnPoints();
} else if (this.pendingAction === 'redeem') {
await this.redeemReward();
@@ -216,6 +220,30 @@ function storeLoyaltyTerminal() {
}
},
// Add stamp
async addStamp() {
loyaltyTerminalLog.info('Adding stamp...');
await apiClient.post('/store/loyalty/stamp', {
card_id: this.selectedCard.id,
staff_pin: this.pinDigits
});
Utils.showToast('Stamp added!', 'success');
},
// Redeem stamps
async redeemStamps() {
loyaltyTerminalLog.info('Redeeming stamps...');
await apiClient.post('/store/loyalty/stamp/redeem', {
card_id: this.selectedCard.id,
staff_pin: this.pinDigits
});
Utils.showToast('Stamps redeemed! Reward earned.', 'success');
},
// Earn points
async earnPoints() {
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });

View File

@@ -26,8 +26,28 @@
{{ error_state('Error loading members') }}
<!-- No Program Setup Notice -->
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-start">
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
{% else %}
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
{% endif %}
</div>
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-4">
<div x-show="!loading && program" class="grid gap-6 mb-8 md:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
@@ -67,7 +87,7 @@
</div>
<!-- Search and Filters -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]">
<div class="relative">
@@ -91,7 +111,7 @@
</div>
<!-- Cards Table -->
<div x-show="!loading">
<div x-show="!loading && program">
{% call table_wrapper() %}
{{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">

View File

@@ -0,0 +1,246 @@
{# app/modules/loyalty/templates/loyalty/store/settings.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}loyaltySettings(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Loyalty Settings', subtitle='Configure your loyalty program') %}
<div class="flex items-center gap-3">
<a href="/store/{{ store_code }}/loyalty/terminal"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
<span x-html="$icon('terminal', 'w-4 h-4 mr-2')"></span>
Terminal
</a>
</div>
{% endcall %}
{{ loading_state('Loading settings...') }}
{{ error_state('Error loading settings') }}
<!-- Access Denied (non-owner) -->
<div x-show="!loading && !isOwner" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-start">
<span x-html="$icon('shield', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Access Restricted</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Only the merchant owner can manage loyalty program settings.</p>
</div>
</div>
</div>
<!-- Settings Form -->
<div x-show="!loading && isOwner" class="space-y-6">
<!-- Program Type (create only) -->
<div x-show="!program" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('layers', 'inline w-5 h-5 mr-2')"></span>
Program Type
</h3>
</div>
<div class="p-4">
<div class="grid gap-4 md:grid-cols-3">
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'stamps' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="stamps" class="sr-only">
<div>
<span x-html="$icon('stamp', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Stamps</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Collect stamps for rewards</p>
</div>
</label>
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'points' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="points" class="sr-only">
<div>
<span x-html="$icon('coins', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Points</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Earn points per purchase</p>
</div>
</label>
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="form.loyalty_type === 'hybrid' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
<input type="radio" x-model="form.loyalty_type" value="hybrid" class="sr-only">
<div>
<span x-html="$icon('layers', 'w-8 h-8 text-purple-500 mb-2')"></span>
<p class="font-semibold text-gray-700 dark:text-gray-200">Hybrid</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Both stamps and points</p>
</div>
</label>
</div>
</div>
</div>
<!-- Stamps Configuration -->
<div x-show="form.loyalty_type === 'stamps' || form.loyalty_type === 'hybrid'"
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Stamps Configuration</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stamps for Reward</label>
<input type="number" x-model.number="form.stamps_target" min="1" max="50"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Description</label>
<input type="text" x-model="form.stamps_reward_description" maxlength="255"
placeholder="e.g., Free coffee"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Value (cents)</label>
<input type="number" x-model.number="form.stamps_reward_value_cents" min="0"
placeholder="e.g., 500 for 5 EUR"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
</div>
<!-- Points Configuration -->
<div x-show="form.loyalty_type === 'points' || form.loyalty_type === 'hybrid'"
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Points Configuration</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points per EUR</label>
<input type="number" x-model.number="form.points_per_euro" min="1" max="100"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Welcome Bonus Points</label>
<input type="number" x-model.number="form.welcome_bonus_points" min="0"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Redemption Points</label>
<input type="number" x-model.number="form.minimum_redemption_points" min="1"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Purchase (cents)</label>
<input type="number" x-model.number="form.minimum_purchase_cents" min="0"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points Expiry (days)</label>
<input type="number" x-model.number="form.points_expiration_days" min="30"
placeholder="Leave empty for no expiry"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Rewards List -->
<div class="px-4 pb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rewards</label>
<div class="space-y-2">
<template x-for="(reward, index) in form.points_rewards" :key="index">
<div class="flex items-center gap-2">
<input type="text" x-model="reward.name" placeholder="Reward name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
<input type="number" x-model.number="reward.points_required" placeholder="Points" min="1"
class="w-24 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
<button @click="form.points_rewards.splice(index, 1)" type="button"
class="p-2 text-red-500 hover:text-red-700">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</template>
</div>
<button @click="addReward()" type="button"
class="mt-2 text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
+ Add Reward
</button>
</div>
</div>
<!-- Anti-Fraud -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Anti-Fraud Settings</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cooldown (minutes)</label>
<input type="number" x-model.number="form.cooldown_minutes" min="0" max="1440"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max Daily Stamps</label>
<input type="number" x-model.number="form.max_daily_stamps" min="1" max="50"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.require_staff_pin"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Require Staff PIN</span>
</label>
</div>
</div>
</div>
<!-- Branding -->
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Branding</h3>
</div>
<div class="p-4 grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Name</label>
<input type="text" x-model="form.card_name" maxlength="100"
placeholder="e.g., My Rewards Card"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Color</label>
<div class="flex items-center gap-2">
<input type="color" x-model="form.card_color"
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer">
<input type="text" x-model="form.card_color" maxlength="7" placeholder="#4F46E5"
class="flex-1 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Logo URL</label>
<input type="url" x-model="form.logo_url" maxlength="500"
placeholder="https://..."
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Terms & Conditions</label>
<textarea x-model="form.terms_text" rows="3"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<a href="/store/{{ store_code }}/loyalty/terminal"
class="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
Cancel
</a>
<button @click="saveProgram()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
<span x-text="saving ? 'Saving...' : (program ? 'Save Changes' : 'Create Program')"></span>
</button>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
{% endblock %}

View File

@@ -17,7 +17,27 @@
{{ loading_state('Loading statistics...') }}
{{ error_state('Error loading statistics') }}
<div x-show="!loading">
<!-- No Program Setup Notice -->
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-start">
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
{% else %}
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
{% endif %}
</div>
</div>
</div>
<div x-show="!loading && program">
<!-- Summary Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">

View File

@@ -36,11 +36,15 @@
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
{% if user.role == 'merchant_owner' %}
<a href="/store/{{ store_code }}/loyalty/settings"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Set Up Loyalty Program
</a>
{% else %}
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
{% endif %}
</div>
</div>
</div>
@@ -125,18 +129,73 @@
</button>
</div>
<!-- Points Balance -->
<!-- Balance Area -->
<div class="mb-6 p-4 rounded-lg text-center"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '10'">
<!-- Points balance (for points and hybrid) -->
<template x-if="program?.is_points_enabled">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
<p class="text-3xl font-bold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
</div>
</template>
<!-- Stamps progress (for stamps and hybrid) -->
<template x-if="program?.is_stamps_enabled">
<div :class="program?.is_points_enabled ? 'mt-3 pt-3 border-t border-gray-200 dark:border-gray-700' : ''">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Stamps</p>
<p class="text-3xl font-bold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="(selectedCard?.stamp_count || 0) + ' / ' + (program?.stamps_target || 10)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"
x-text="selectedCard?.stamps_until_reward > 0 ? (selectedCard.stamps_until_reward + ' more for reward') : 'Ready to redeem!'"></p>
</div>
</template>
</div>
<!-- Action Buttons -->
<!-- Action Panels -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Earn Points -->
<!-- Stamp Panels (for stamps and hybrid) -->
<template x-if="program?.is_stamps_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
Add Stamp
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Current: <span class="font-semibold" x-text="(selectedCard?.stamp_count || 0) + '/' + (program?.stamps_target || 10)"></span>
</p>
<button @click="showPinModal('stamp')"
:disabled="!selectedCard?.can_stamp"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
Add Stamp
</button>
<template x-if="!selectedCard?.can_stamp && selectedCard?.cooldown_ends_at">
<p class="text-xs text-red-500 mt-2">Cooldown active</p>
</template>
</div>
</template>
<template x-if="program?.is_stamps_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
Redeem Stamps
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"
x-text="selectedCard?.can_redeem_stamps ? 'Reward: ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || 'Free item') : 'Not enough stamps yet'"></p>
<button @click="showPinModal('redeemStamps')"
:disabled="!selectedCard?.can_redeem_stamps"
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
Redeem Stamps
</button>
</div>
</template>
<!-- Point Panels (for points and hybrid) -->
<template x-if="program?.is_points_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
@@ -160,8 +219,9 @@
Award Points
</button>
</div>
</template>
<!-- Redeem Points -->
<template x-if="program?.is_points_enabled">
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
@@ -189,6 +249,7 @@
Redeem Reward
</button>
</div>
</template>
</div>
</div>
</div>

View File

@@ -12,12 +12,15 @@ Tests:
Authentication: Uses real JWT tokens via store login endpoint.
"""
import uuid
from datetime import UTC, datetime
import pytest
from app.modules.loyalty.models import LoyaltyTransaction
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
from app.modules.loyalty.models.loyalty_program import LoyaltyType
from app.modules.loyalty.models.loyalty_transaction import TransactionType
from app.modules.tenancy.models import User
BASE = "/api/v1/store/loyalty"
@@ -217,42 +220,20 @@ class TestEarnPoints:
# ============================================================================
# Store Program CRUD Removed
# Store Program CRUD
# ============================================================================
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestStoreProgramCrudRemoved:
"""Verify POST/PATCH /program endpoints are removed from store API."""
class TestStoreProgramCrud:
"""Tests for store program CRUD endpoints (merchant_owner only)."""
def test_create_program_removed(
self, client, loyalty_store_headers
):
"""POST /program no longer exists on store API."""
response = client.post(
f"{BASE}/program",
json={"loyalty_type": "points"},
headers=loyalty_store_headers,
)
assert response.status_code == 405 # Method Not Allowed
def test_update_program_removed(
self, client, loyalty_store_headers
):
"""PATCH /program no longer exists on store API."""
response = client.patch(
f"{BASE}/program",
json={"card_name": "Should Not Work"},
headers=loyalty_store_headers,
)
assert response.status_code == 405 # Method Not Allowed
def test_get_program_still_works(
def test_get_program_works(
self, client, loyalty_store_headers, loyalty_store_setup
):
"""GET /program still works (read-only)."""
"""GET /program returns program."""
response = client.get(
f"{BASE}/program",
headers=loyalty_store_headers,
@@ -261,6 +242,19 @@ class TestStoreProgramCrudRemoved:
data = response.json()
assert data["id"] == loyalty_store_setup["program"].id
def test_update_program_via_put(
self, client, loyalty_store_headers, loyalty_store_setup
):
"""PUT /program updates the program (merchant_owner)."""
response = client.put(
f"{BASE}/program",
json={"card_name": "Updated Card"},
headers=loyalty_store_headers,
)
assert response.status_code == 200
data = response.json()
assert data["card_name"] == "Updated Card"
def test_stats_still_works(
self, client, loyalty_store_headers, loyalty_store_setup
):
@@ -270,3 +264,201 @@ class TestStoreProgramCrudRemoved:
headers=loyalty_store_headers,
)
assert response.status_code == 200
def test_stats_has_new_fields(
self, client, loyalty_store_headers, loyalty_store_setup
):
"""GET /stats returns all new fields."""
response = client.get(
f"{BASE}/stats",
headers=loyalty_store_headers,
)
assert response.status_code == 200
data = response.json()
assert "new_this_month" in data
assert "total_points_balance" in data
assert "avg_points_per_member" in data
assert "transactions_30d" in data
assert "points_issued_30d" in data
assert "points_redeemed_30d" in data
# ============================================================================
# Stamp Earn/Redeem Integration Tests
# ============================================================================
@pytest.fixture
def stamp_store_setup(db, loyalty_platform):
"""Setup with a stamps-type program for integration tests."""
from app.modules.customers.models.customer import Customer
from app.modules.tenancy.models.store import StoreUser
from app.modules.tenancy.models.store_platform import StorePlatform
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"stampint_{uid}@test.com",
username=f"stampint_{uid}",
hashed_password=auth.hash_password("storepass123"),
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
db.add(owner)
db.commit()
db.refresh(owner)
from app.modules.tenancy.models import Merchant, Store
merchant = Merchant(
name=f"Stamp Int Merchant {uid}",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
store = Store(
merchant_id=merchant.id,
store_code=f"STINT_{uid.upper()}",
subdomain=f"stint{uid}",
name=f"Stamp Int Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
db.add(store_user)
db.commit()
sp = StorePlatform(store_id=store.id, platform_id=loyalty_platform.id)
db.add(sp)
db.commit()
customer = Customer(
email=f"stampcust_{uid}@test.com",
first_name="Stamp",
last_name="IntCustomer",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"SIC-{uid.upper()}",
store_id=store.id,
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
program = LoyaltyProgram(
merchant_id=merchant.id,
loyalty_type=LoyaltyType.HYBRID.value,
stamps_target=3,
stamps_reward_description="Free item",
stamps_reward_value_cents=500,
points_per_euro=10,
welcome_bonus_points=0,
minimum_redemption_points=100,
minimum_purchase_cents=0,
cooldown_minutes=0,
max_daily_stamps=50,
require_staff_pin=False,
card_name="Int Stamp Card",
card_color="#FF0000",
is_active=True,
points_rewards=[
{"id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True},
],
)
db.add(program)
db.commit()
db.refresh(program)
card = LoyaltyCard(
merchant_id=merchant.id,
program_id=program.id,
customer_id=customer.id,
enrolled_at_store_id=store.id,
card_number=f"SINTCARD-{uid.upper()}",
stamp_count=0,
total_stamps_earned=0,
stamps_redeemed=0,
points_balance=0,
total_points_earned=0,
points_redeemed=0,
is_active=True,
last_activity_at=datetime.now(UTC),
)
db.add(card)
db.commit()
db.refresh(card)
return {
"owner": owner,
"merchant": merchant,
"store": store,
"platform": loyalty_platform,
"customer": customer,
"program": program,
"card": card,
}
@pytest.fixture
def stamp_store_headers(client, stamp_store_setup):
"""JWT auth headers for stamp store setup."""
owner = stamp_store_setup["owner"]
response = client.post(
"/api/v1/store/auth/login",
json={"email_or_username": owner.username, "password": "storepass123"},
)
assert response.status_code == 200
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestStampEarnRedeem:
"""Integration tests for stamp earn/redeem via store API."""
def test_stamp_earn(self, client, stamp_store_headers, stamp_store_setup):
"""POST /stamp adds a stamp."""
card = stamp_store_setup["card"]
response = client.post(
f"{BASE}/stamp",
json={"card_id": card.id},
headers=stamp_store_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["stamp_count"] == 1
def test_stamp_redeem(self, client, stamp_store_headers, stamp_store_setup, db):
"""POST /stamp/redeem redeems stamps."""
card = stamp_store_setup["card"]
program = stamp_store_setup["program"]
# Give enough stamps
card.stamp_count = program.stamps_target
card.total_stamps_earned = program.stamps_target
db.commit()
response = client.post(
f"{BASE}/stamp/redeem",
json={"card_id": card.id},
headers=stamp_store_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["stamp_count"] == 0

View File

@@ -1,8 +1,20 @@
"""Unit tests for PinService."""
import uuid
from datetime import UTC, datetime
import pytest
from app.modules.loyalty.exceptions import (
InvalidStaffPinException,
StaffPinLockedException,
)
from app.modules.loyalty.models import LoyaltyProgram, StaffPin
from app.modules.loyalty.models.loyalty_program import LoyaltyType
from app.modules.loyalty.schemas.pin import PinCreate
from app.modules.loyalty.services.pin_service import PinService
from app.modules.tenancy.models import Merchant, Store, User
from app.modules.tenancy.models.store import StoreUser
@pytest.mark.unit
@@ -16,3 +28,208 @@ class TestPinService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def pin_setup(db):
"""Create a full setup for PIN tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"pinowner_{uid}@test.com",
username=f"pinowner_{uid}",
hashed_password=auth.hash_password("testpass"),
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
db.add(owner)
db.commit()
db.refresh(owner)
merchant = Merchant(
name=f"PIN Merchant {uid}",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
store = Store(
merchant_id=merchant.id,
store_code=f"PIN_{uid.upper()}",
subdomain=f"pin{uid}",
name=f"PIN Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
db.add(store_user)
db.commit()
program = LoyaltyProgram(
merchant_id=merchant.id,
loyalty_type=LoyaltyType.POINTS.value,
points_per_euro=10,
cooldown_minutes=0,
max_daily_stamps=10,
require_staff_pin=True,
card_name="PIN Card",
card_color="#00FF00",
is_active=True,
)
db.add(program)
db.commit()
db.refresh(program)
return {
"merchant": merchant,
"store": store,
"program": program,
}
# ============================================================================
# Create / Unlock Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestCreatePin:
"""Tests for create_pin."""
def setup_method(self):
self.service = PinService()
def test_create_pin(self, db, pin_setup):
"""Create a staff PIN."""
program = pin_setup["program"]
store = pin_setup["store"]
data = PinCreate(name="Alice", staff_id="EMP001", pin="1234")
pin = self.service.create_pin(db, program.id, store.id, data)
assert pin.id is not None
assert pin.name == "Alice"
assert pin.staff_id == "EMP001"
assert pin.verify_pin("1234")
def test_unlock_pin(self, db, pin_setup):
"""Unlock a locked PIN."""
program = pin_setup["program"]
store = pin_setup["store"]
data = PinCreate(name="Bob", staff_id="EMP002", pin="5678")
pin = self.service.create_pin(db, program.id, store.id, data)
# Lock it
pin.failed_attempts = 5
from datetime import timedelta
pin.locked_until = datetime.now(UTC) + timedelta(minutes=30)
db.commit()
assert pin.is_locked
# Unlock
unlocked = self.service.unlock_pin(db, pin.id)
assert unlocked.failed_attempts == 0
assert unlocked.locked_until is None
assert not unlocked.is_locked
# ============================================================================
# Verify PIN Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestVerifyPin:
"""Tests for verify_pin."""
def setup_method(self):
self.service = PinService()
def test_verify_pin_success(self, db, pin_setup):
"""Correct PIN verifies successfully."""
program = pin_setup["program"]
store = pin_setup["store"]
data = PinCreate(name="Charlie", staff_id="EMP003", pin="1111")
self.service.create_pin(db, program.id, store.id, data)
result = self.service.verify_pin(db, program.id, "1111", store_id=store.id)
assert result.name == "Charlie"
def test_verify_pin_wrong_single_failure(self, db, pin_setup):
"""Wrong PIN records failure on ONE pin only, not all."""
program = pin_setup["program"]
store = pin_setup["store"]
# Create two PINs
self.service.create_pin(db, program.id, store.id, PinCreate(name="A", pin="1111"))
self.service.create_pin(db, program.id, store.id, PinCreate(name="B", pin="2222"))
# Wrong PIN
with pytest.raises(InvalidStaffPinException):
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
# Only one PIN should have failed_attempts incremented
pins = self.service.list_pins(db, program.id, store_id=store.id, is_active=True)
failed_counts = [p.failed_attempts for p in pins]
assert sum(failed_counts) == 1 # Only 1 PIN got the failure, not both
def test_verify_pin_lockout(self, db, pin_setup):
"""After max failures, PIN gets locked."""
program = pin_setup["program"]
store = pin_setup["store"]
self.service.create_pin(db, program.id, store.id, PinCreate(name="Lock", pin="3333"))
# Fail 5 times (default max)
for _ in range(5):
try:
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
except (InvalidStaffPinException, StaffPinLockedException):
pass
# Next attempt should be locked
with pytest.raises((InvalidStaffPinException, StaffPinLockedException)):
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
def test_verify_skips_locked_pins(self, db, pin_setup):
"""Locked PINs are skipped during verification."""
program = pin_setup["program"]
store = pin_setup["store"]
from datetime import timedelta
# Create a locked PIN and an unlocked one
data1 = PinCreate(name="Locked", pin="1111")
pin1 = self.service.create_pin(db, program.id, store.id, data1)
pin1.locked_until = datetime.now(UTC) + timedelta(minutes=30)
pin1.failed_attempts = 5
db.commit()
data2 = PinCreate(name="Active", pin="2222")
self.service.create_pin(db, program.id, store.id, data2)
# Should find the active PIN
result = self.service.verify_pin(db, program.id, "2222", store_id=store.id)
assert result.name == "Active"

View File

@@ -1,8 +1,20 @@
"""Unit tests for PointsService."""
import uuid
from datetime import UTC, datetime
import pytest
from app.modules.loyalty.exceptions import (
InsufficientPointsException,
InvalidRewardException,
)
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
from app.modules.loyalty.models.loyalty_program import LoyaltyType
from app.modules.loyalty.models.loyalty_transaction import TransactionType
from app.modules.loyalty.services.points_service import PointsService
from app.modules.tenancy.models import Merchant, Store, User
from app.modules.tenancy.models.store import StoreUser
@pytest.mark.unit
@@ -16,3 +28,347 @@ class TestPointsService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def points_setup(db):
"""Create a full setup for points tests."""
from app.modules.customers.models.customer import Customer
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"ptsowner_{uid}@test.com",
username=f"ptsowner_{uid}",
hashed_password=auth.hash_password("testpass"),
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
db.add(owner)
db.commit()
db.refresh(owner)
merchant = Merchant(
name=f"Points Merchant {uid}",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
store = Store(
merchant_id=merchant.id,
store_code=f"PTS_{uid.upper()}",
subdomain=f"pts{uid}",
name=f"Points Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
db.add(store_user)
db.commit()
customer = Customer(
email=f"ptscust_{uid}@test.com",
first_name="Points",
last_name="Customer",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"PC-{uid.upper()}",
store_id=store.id,
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
program = LoyaltyProgram(
merchant_id=merchant.id,
loyalty_type=LoyaltyType.POINTS.value,
points_per_euro=10,
welcome_bonus_points=0,
minimum_redemption_points=50,
minimum_purchase_cents=100,
cooldown_minutes=0,
max_daily_stamps=10,
require_staff_pin=False,
card_name="Points Card",
card_color="#0000FF",
is_active=True,
points_rewards=[
{"id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True},
{"id": "r2", "name": "10 EUR off", "points_required": 200, "is_active": True},
{"id": "r3", "name": "Inactive Reward", "points_required": 50, "is_active": False},
],
)
db.add(program)
db.commit()
db.refresh(program)
card = LoyaltyCard(
merchant_id=merchant.id,
program_id=program.id,
customer_id=customer.id,
enrolled_at_store_id=store.id,
card_number=f"PTSCARD-{uid.upper()}",
stamp_count=0,
total_stamps_earned=0,
stamps_redeemed=0,
points_balance=500,
total_points_earned=500,
points_redeemed=0,
is_active=True,
last_activity_at=datetime.now(UTC),
)
db.add(card)
db.commit()
db.refresh(card)
return {
"merchant": merchant,
"store": store,
"customer": customer,
"program": program,
"card": card,
}
# ============================================================================
# Earn Points Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestEarnPoints:
"""Tests for earn_points."""
def setup_method(self):
self.service = PointsService()
def test_earn_points_calculation(self, db, points_setup):
"""Points calculated correctly from purchase amount."""
card = points_setup["card"]
store = points_setup["store"]
result = self.service.earn_points(
db,
store_id=store.id,
card_id=card.id,
purchase_amount_cents=2000, # 20 EUR
)
assert result["success"] is True
assert result["points_earned"] == 200 # 20 EUR * 10 pts/EUR
assert result["points_balance"] == 700 # 500 + 200
def test_earn_points_minimum_purchase(self, db, points_setup):
"""Below minimum purchase returns 0 points."""
card = points_setup["card"]
store = points_setup["store"]
result = self.service.earn_points(
db,
store_id=store.id,
card_id=card.id,
purchase_amount_cents=50, # Below min of 100
)
assert result["success"] is True
assert result["points_earned"] == 0
# ============================================================================
# Redeem Points Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestRedeemPoints:
"""Tests for redeem_points."""
def setup_method(self):
self.service = PointsService()
def test_redeem_points_success(self, db, points_setup):
"""Successfully redeem points for a reward."""
card = points_setup["card"]
store = points_setup["store"]
result = self.service.redeem_points(
db,
store_id=store.id,
card_id=card.id,
reward_id="r1",
)
assert result["success"] is True
assert result["points_spent"] == 100
assert result["points_balance"] == 400 # 500 - 100
def test_redeem_points_insufficient(self, db, points_setup):
"""Redeeming without enough points raises exception."""
card = points_setup["card"]
store = points_setup["store"]
# Set balance low
card.points_balance = 50
db.commit()
with pytest.raises(InsufficientPointsException):
self.service.redeem_points(
db,
store_id=store.id,
card_id=card.id,
reward_id="r1", # needs 100
)
def test_redeem_inactive_reward(self, db, points_setup):
"""Redeeming an inactive reward raises exception."""
card = points_setup["card"]
store = points_setup["store"]
with pytest.raises(InvalidRewardException):
self.service.redeem_points(
db,
store_id=store.id,
card_id=card.id,
reward_id="r3", # inactive
)
# ============================================================================
# Void Points Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestVoidPoints:
"""Tests for void_points."""
def setup_method(self):
self.service = PointsService()
def test_void_by_transaction(self, db, points_setup):
"""Void points by original transaction ID."""
card = points_setup["card"]
store = points_setup["store"]
# Earn some points
earn_result = self.service.earn_points(
db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000,
)
assert earn_result["points_earned"] == 100
# Find the earn transaction
tx = (
db.query(LoyaltyTransaction)
.filter(
LoyaltyTransaction.card_id == card.id,
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
)
.first()
)
void_result = self.service.void_points(
db, store_id=store.id, card_id=card.id, original_transaction_id=tx.id,
)
assert void_result["success"] is True
assert void_result["points_voided"] == 100
def test_void_by_order_reference(self, db, points_setup):
"""Void points by order reference."""
card = points_setup["card"]
store = points_setup["store"]
# Earn with order reference
self.service.earn_points(
db,
store_id=store.id,
card_id=card.id,
purchase_amount_cents=2000,
order_reference="ORDER-VOID-TEST",
)
void_result = self.service.void_points(
db,
store_id=store.id,
card_id=card.id,
order_reference="ORDER-VOID-TEST",
)
assert void_result["success"] is True
assert void_result["points_voided"] == 200
# ============================================================================
# Adjust Points Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestAdjustPoints:
"""Tests for adjust_points."""
def setup_method(self):
self.service = PointsService()
def test_adjust_positive(self, db, points_setup):
"""Add points via adjustment."""
card = points_setup["card"]
result = self.service.adjust_points(
db,
card_id=card.id,
points_delta=50,
reason="Goodwill bonus",
)
assert result["success"] is True
assert result["points_balance"] == 550 # 500 + 50
def test_adjust_negative(self, db, points_setup):
"""Remove points via adjustment."""
card = points_setup["card"]
result = self.service.adjust_points(
db,
card_id=card.id,
points_delta=-100,
reason="Correction",
)
assert result["success"] is True
assert result["points_balance"] == 400 # 500 - 100
def test_adjust_floor_at_zero(self, db, points_setup):
"""Negative adjustment doesn't go below zero."""
card = points_setup["card"]
result = self.service.adjust_points(
db,
card_id=card.id,
points_delta=-9999,
reason="Full correction",
)
assert result["success"] is True
assert result["points_balance"] == 0

View File

@@ -352,3 +352,98 @@ class TestDeleteProgram:
"""Deleting non-existent program raises exception."""
with pytest.raises(LoyaltyProgramNotFoundException):
self.service.delete_program(db, 999999)
# ============================================================================
# Stats
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestGetProgramStats:
"""Tests for get_program_stats."""
def setup_method(self):
self.service = ProgramService()
def test_stats_returns_all_fields(self, db, ps_program):
"""Stats response includes all required fields."""
stats = self.service.get_program_stats(db, ps_program.id)
assert "total_cards" in stats
assert "active_cards" in stats
assert "new_this_month" in stats
assert "total_points_balance" in stats
assert "avg_points_per_member" in stats
assert "transactions_30d" in stats
assert "points_issued_30d" in stats
assert "points_redeemed_30d" in stats
assert "points_this_month" in stats
assert "points_redeemed_this_month" in stats
assert "estimated_liability_cents" in stats
def test_stats_empty_program(self, db, ps_program):
"""Stats for program with no cards."""
stats = self.service.get_program_stats(db, ps_program.id)
assert stats["total_cards"] == 0
assert stats["active_cards"] == 0
assert stats["new_this_month"] == 0
assert stats["total_points_balance"] == 0
assert stats["avg_points_per_member"] == 0
def test_stats_with_cards(self, db, ps_program, ps_merchant):
"""Stats reflect actual card data."""
from datetime import UTC, datetime
from app.modules.customers.models.customer import Customer
from app.modules.loyalty.models import LoyaltyCard
from app.modules.tenancy.models import Store
uid_store = uuid.uuid4().hex[:8]
store = Store(
merchant_id=ps_merchant.id,
store_code=f"STAT_{uid_store.upper()}",
subdomain=f"stat{uid_store}",
name=f"Stats Store {uid_store}",
is_active=True,
is_verified=True,
)
db.add(store)
db.flush()
# Create cards with customers
for i in range(3):
uid = uuid.uuid4().hex[:8]
customer = Customer(
email=f"stat_{uid}@test.com",
first_name="Stat",
last_name=f"Customer{i}",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"SC-{uid.upper()}",
store_id=store.id,
is_active=True,
)
db.add(customer)
db.flush()
card = LoyaltyCard(
merchant_id=ps_merchant.id,
program_id=ps_program.id,
customer_id=customer.id,
card_number=f"STAT-{i}-{uuid.uuid4().hex[:6]}",
points_balance=100 * (i + 1),
total_points_earned=100 * (i + 1),
is_active=True,
last_activity_at=datetime.now(UTC),
)
db.add(card)
db.commit()
stats = self.service.get_program_stats(db, ps_program.id)
assert stats["total_cards"] == 3
assert stats["active_cards"] == 3
assert stats["total_points_balance"] == 600 # 100+200+300
assert stats["avg_points_per_member"] == 200.0 # 600/3

View File

@@ -1,8 +1,21 @@
"""Unit tests for StampService."""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.loyalty.exceptions import (
DailyStampLimitException,
InsufficientStampsException,
StampCooldownException,
)
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
from app.modules.loyalty.models.loyalty_program import LoyaltyType
from app.modules.loyalty.models.loyalty_transaction import TransactionType
from app.modules.loyalty.services.stamp_service import StampService
from app.modules.tenancy.models import Merchant, Store, User
from app.modules.tenancy.models.store import StoreUser
@pytest.mark.unit
@@ -16,3 +29,259 @@ class TestStampService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def stamp_setup(db):
"""Create a full setup for stamp tests with stamps-type program."""
from app.modules.customers.models.customer import Customer
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"stampowner_{uid}@test.com",
username=f"stampowner_{uid}",
hashed_password=auth.hash_password("testpass"),
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
db.add(owner)
db.commit()
db.refresh(owner)
merchant = Merchant(
name=f"Stamp Merchant {uid}",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
store = Store(
merchant_id=merchant.id,
store_code=f"STAMP_{uid.upper()}",
subdomain=f"stamp{uid}",
name=f"Stamp Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
db.add(store_user)
db.commit()
customer = Customer(
email=f"stampcust_{uid}@test.com",
first_name="Stamp",
last_name="Customer",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"SC-{uid.upper()}",
store_id=store.id,
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
program = LoyaltyProgram(
merchant_id=merchant.id,
loyalty_type=LoyaltyType.STAMPS.value,
stamps_target=5,
stamps_reward_description="Free coffee",
stamps_reward_value_cents=500,
cooldown_minutes=0,
max_daily_stamps=10,
require_staff_pin=False,
card_name="Stamp Card",
card_color="#FF0000",
is_active=True,
points_per_euro=1,
)
db.add(program)
db.commit()
db.refresh(program)
card = LoyaltyCard(
merchant_id=merchant.id,
program_id=program.id,
customer_id=customer.id,
enrolled_at_store_id=store.id,
card_number=f"STAMPCARD-{uid.upper()}",
stamp_count=0,
total_stamps_earned=0,
stamps_redeemed=0,
points_balance=0,
total_points_earned=0,
points_redeemed=0,
is_active=True,
last_activity_at=datetime.now(UTC),
)
db.add(card)
db.commit()
db.refresh(card)
return {
"merchant": merchant,
"store": store,
"customer": customer,
"program": program,
"card": card,
}
# ============================================================================
# Add Stamp Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestAddStamp:
"""Tests for add_stamp."""
def setup_method(self):
self.service = StampService()
def test_add_stamp_success(self, db, stamp_setup):
"""Successfully add a stamp to a card."""
card = stamp_setup["card"]
store = stamp_setup["store"]
result = self.service.add_stamp(db, store_id=store.id, card_id=card.id)
assert result["success"] is True
assert result["stamp_count"] == 1
assert result["stamps_target"] == 5
assert result["stamps_until_reward"] == 4
def test_add_stamp_cooldown_violation(self, db, stamp_setup):
"""Stamp within cooldown period raises exception."""
card = stamp_setup["card"]
store = stamp_setup["store"]
program = stamp_setup["program"]
# Set cooldown
program.cooldown_minutes = 15
db.commit()
# Add first stamp
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
# Second stamp should fail (cooldown)
with pytest.raises(StampCooldownException):
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
def test_add_stamp_daily_limit(self, db, stamp_setup):
"""Exceeding daily stamp limit raises exception."""
card = stamp_setup["card"]
store = stamp_setup["store"]
program = stamp_setup["program"]
# Set max 2 daily stamps
program.max_daily_stamps = 2
db.commit()
# Add 2 stamps
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
# Third should fail
with pytest.raises(DailyStampLimitException):
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
# ============================================================================
# Redeem Stamps Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestRedeemStamps:
"""Tests for redeem_stamps."""
def setup_method(self):
self.service = StampService()
def test_redeem_stamps_success(self, db, stamp_setup):
"""Successfully redeem stamps for a reward."""
card = stamp_setup["card"]
store = stamp_setup["store"]
program = stamp_setup["program"]
# Give enough stamps
card.stamp_count = program.stamps_target
card.total_stamps_earned = program.stamps_target
db.commit()
result = self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
assert result["success"] is True
assert result["stamp_count"] == 0
assert result["reward_description"] == "Free coffee"
def test_redeem_stamps_insufficient(self, db, stamp_setup):
"""Redeeming without enough stamps raises exception."""
card = stamp_setup["card"]
store = stamp_setup["store"]
# Card has 0 stamps, needs 5
with pytest.raises(InsufficientStampsException):
self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
# ============================================================================
# Void Stamps Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.loyalty
class TestVoidStamps:
"""Tests for void_stamps."""
def setup_method(self):
self.service = StampService()
def test_void_stamps_by_transaction(self, db, stamp_setup):
"""Void stamps by original transaction ID."""
card = stamp_setup["card"]
store = stamp_setup["store"]
# Add a stamp first
result = self.service.add_stamp(db, store_id=store.id, card_id=card.id)
assert result["stamp_count"] == 1
# Find the transaction
tx = (
db.query(LoyaltyTransaction)
.filter(
LoyaltyTransaction.card_id == card.id,
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
)
.first()
)
void_result = self.service.void_stamps(
db,
store_id=store.id,
card_id=card.id,
original_transaction_id=tx.id,
)
assert void_result["success"] is True
assert void_result["stamp_count"] == 0

View File

@@ -87,13 +87,6 @@ class StorePlatform(Base, TimestampMixin):
comment="Whether the store is active on this platform",
)
is_primary = Column(
Boolean,
default=False,
nullable=False,
comment="Whether this is the store's primary platform",
)
# ========================================================================
# Platform-Specific Configuration
# ========================================================================
@@ -165,11 +158,6 @@ class StorePlatform(Base, TimestampMixin):
"platform_id",
"is_active",
),
Index(
"idx_store_platform_primary",
"store_id",
"is_primary",
),
)
# ========================================================================

View File

@@ -48,6 +48,7 @@ class StoreLoginResponse(BaseModel):
user: dict
store: dict
store_role: str
platform_code: str | None = None
@store_auth_router.post("/login", response_model=StoreLoginResponse)
@@ -116,25 +117,46 @@ def store_login(
f"for store {store.store_code} as {store_role}"
)
# 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
# Resolve platform — prefer explicit sources, fall back to store's primary platform
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
platform = None
# Source 1: middleware-detected platform (production domain-based)
mw_platform = get_current_platform(request)
if mw_platform and mw_platform.code != "main":
platform = mw_platform
# Source 2: platform_code from login body (dev mode — JS sends platform from page context)
if platform is None and user_credentials.platform_code:
platform = platform_service.get_platform_by_code_optional(
db, user_credentials.platform_code
)
if not platform:
raise InvalidCredentialsException(
f"Unknown platform: {user_credentials.platform_code}"
)
# Source 3: fall back to store's primary platform
if platform is None:
primary_pid = platform_service.get_first_active_platform_id_for_store(
db, store.id
)
if primary_pid:
platform = platform_service.get_platform_by_id(db, primary_pid)
# Verify store-platform link if platform was resolved explicitly (source 1 or 2)
if platform is not None and (
mw_platform or user_credentials.platform_code
):
link = platform_service.get_store_platform_entry(
db, store.id, platform.id
)
if not link or not link.is_active:
raise InvalidCredentialsException(
f"Store {store.store_code} is not available on platform {platform.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
@@ -186,6 +208,7 @@ def store_login(
"is_verified": store.is_verified,
},
store_role=store_role,
platform_code=platform_code,
)
@@ -226,6 +249,7 @@ def get_current_store_user(
email=user.email,
role=user.role,
is_active=user.is_active,
platform_code=user.token_platform_code,
)

View File

@@ -64,20 +64,36 @@ async def store_login_page(
"""
Render store login page.
If user is already authenticated as store, redirect to dashboard.
Otherwise, show login form.
If user is already authenticated on the SAME platform, redirect to dashboard.
If authenticated on a DIFFERENT platform, show login form so the user can
re-login with the correct platform context.
"""
if current_user:
return RedirectResponse(
url=f"/store/{store_code}/dashboard", status_code=302
)
language = getattr(request.state, "language", "fr")
platform = getattr(request.state, "platform", None)
platform_code = platform.code if platform else None
if current_user:
# If the URL's platform matches the JWT's platform (or no platform in URL),
# redirect to dashboard. Otherwise, show login form for platform switch.
jwt_platform = current_user.token_platform_code
same_platform = (
platform_code is None
or jwt_platform is None
or platform_code == jwt_platform
)
if same_platform:
if platform_code:
redirect_url = f"/platforms/{platform_code}/store/{store_code}/dashboard"
else:
redirect_url = f"/store/{store_code}/dashboard"
return RedirectResponse(url=redirect_url, status_code=302)
return templates.TemplateResponse(
"tenancy/store/login.html",
{
"request": request,
"store_code": store_code,
"platform_code": platform_code,
**get_jinja2_globals(language),
},
)

View File

@@ -20,6 +20,9 @@ class UserLogin(BaseModel):
store_code: str | None = Field(
None, description="Optional store code for context"
)
platform_code: str | None = Field(
None, description="Platform code from login context"
)
@field_validator("email_or_username")
@classmethod
@@ -200,6 +203,7 @@ class StoreUserResponse(BaseModel):
email: str
role: str
is_active: bool
platform_code: str | None = None
model_config = {"from_attributes": True}

View File

@@ -115,8 +115,9 @@ class MerchantDomainService:
db.query(StorePlatform)
.filter(
StorePlatform.store_id.in_(store_ids),
StorePlatform.is_primary.is_(True),
StorePlatform.is_active == True, # noqa: E712
)
.order_by(StorePlatform.joined_at)
.first()
)
platform_id = primary_sp.platform_id if primary_sp else None

View File

@@ -324,9 +324,12 @@ class PlatformService:
# ========================================================================
@staticmethod
def get_primary_platform_id_for_store(db: Session, store_id: int) -> int | None:
def get_first_active_platform_id_for_store(db: Session, store_id: int) -> int | None:
"""
Get the primary platform ID for a store.
Get the first active platform ID for a store (ordered by joined_at).
Used as a fallback when platform_id is not available from JWT context
(e.g. background tasks, old tokens).
Args:
db: Database session
@@ -341,7 +344,7 @@ class PlatformService:
StorePlatform.store_id == store_id,
StorePlatform.is_active == True, # noqa: E712
)
.order_by(StorePlatform.is_primary.desc())
.order_by(StorePlatform.joined_at)
.first()
)
return result[0] if result else None
@@ -364,7 +367,7 @@ class PlatformService:
StorePlatform.store_id == store_id,
StorePlatform.is_active == True, # noqa: E712
)
.order_by(StorePlatform.is_primary.desc())
.order_by(StorePlatform.joined_at)
.all()
)
return [r[0] for r in results]
@@ -393,29 +396,6 @@ class PlatformService:
.first()
)
@staticmethod
def get_primary_store_platform_entry(
db: Session, store_id: int
) -> StorePlatform | None:
"""
Get the primary StorePlatform entry for a store.
Args:
db: Database session
store_id: Store ID
Returns:
StorePlatform object or None
"""
return (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.is_primary.is_(True),
)
.first()
)
@staticmethod
def get_store_ids_for_platform(
db: Session, platform_id: int, active_only: bool = True
@@ -450,7 +430,7 @@ class PlatformService:
Upsert a StorePlatform entry.
If the entry exists, update is_active (and tier_id if provided).
If missing and is_active=True, create it (set is_primary if store has none).
If missing and is_active=True, create it.
If missing and is_active=False, no-op.
Args:
@@ -479,20 +459,10 @@ class PlatformService:
return existing
if is_active:
has_primary = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.is_primary.is_(True),
)
.first()
) is not None
sp = StorePlatform(
store_id=store_id,
platform_id=platform_id,
is_active=True,
is_primary=not has_primary,
tier_id=tier_id,
)
db.add(sp)

View File

@@ -105,16 +105,13 @@ class StoreDomainService:
if domain_data.is_primary:
self._unset_primary_domains(db, store_id)
# Resolve platform_id: use provided value, or auto-resolve from primary StorePlatform
# Resolve platform_id: use provided value, or auto-resolve from first active StorePlatform
platform_id = domain_data.platform_id
if not platform_id:
from app.modules.tenancy.models import StorePlatform
primary_sp = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == store_id, StorePlatform.is_primary.is_(True))
.first()
from app.modules.tenancy.services.platform_service import (
platform_service,
)
platform_id = primary_sp.platform_id if primary_sp else None
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
# Create domain record
new_domain = StoreDomain(

View File

@@ -126,7 +126,8 @@ function storeLogin() {
const response = await apiClient.post('/store/auth/login', {
email_or_username: this.credentials.username,
password: this.credentials.password,
store_code: this.storeCode
store_code: this.storeCode,
platform_code: window.STORE_PLATFORM_CODE || localStorage.getItem('store_platform') || null
});
const duration = performance.now() - startTime;
@@ -143,17 +144,30 @@ function storeLogin() {
localStorage.setItem('store_token', response.access_token);
localStorage.setItem('currentUser', JSON.stringify(response.user));
localStorage.setItem('storeCode', this.storeCode);
if (response.platform_code) {
localStorage.setItem('store_platform', response.platform_code);
}
storeLoginLog.debug('Token stored as store_token in localStorage');
this.success = 'Login successful! Redirecting...';
// Build platform-aware base path
const platformCode = window.STORE_PLATFORM_CODE;
const basePath = platformCode
? `/platforms/${platformCode}/store/${this.storeCode}`
: `/store/${this.storeCode}`;
// Check for last visited page (saved before logout)
const lastPage = localStorage.getItem('store_last_visited_page');
const validLastPage = lastPage &&
lastPage.startsWith(`/store/${this.storeCode}/`) &&
!lastPage.includes('/login') &&
!lastPage.includes('/onboarding');
const redirectTo = validLastPage ? lastPage : `/store/${this.storeCode}/dashboard`;
let redirectTo = `${basePath}/dashboard`;
if (lastPage && !lastPage.includes('/login') && !lastPage.includes('/onboarding')) {
// Extract the store-relative path (strip any existing prefix)
const storePathMatch = lastPage.match(/\/store\/[^/]+(\/.*)/);
if (storePathMatch) {
redirectTo = `${basePath}${storePathMatch[1]}`;
}
}
storeLoginLog.info('Last visited page:', lastPage);
storeLoginLog.info('Redirecting to:', redirectTo);

View File

@@ -12,6 +12,9 @@
<style>
[x-cloak] { display: none !important; }
</style>
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -234,7 +237,13 @@
})();
</script>
<!-- 6. Store Login Logic -->
<!-- 6. Platform context for login -->
<script>
window.STORE_PLATFORM_CODE = {{ platform_code|tojson }};
</script>
<!-- 7. Store Login Logic -->
<script defer src="{{ url_for('tenancy_static', path='store/js/login.js') }}"></script>
</body>
</html>

View File

@@ -374,7 +374,18 @@
</div>
</div>
{# Step checklist #}
{# Non-owner message #}
<template x-if="!isOwner">
<div class="px-6 py-4 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
<span x-html="$icon('information-circle', 'inline w-5 h-5 mr-1 text-gray-400')"></span>
Contact your administrator to complete platform setup.
</p>
</div>
</template>
{# Step checklist (owners only) #}
<template x-if="isOwner">
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="step in steps" :key="step.key">
<a :href="step.route"
@@ -412,6 +423,7 @@
</a>
</template>
</div>
</template>
</div>
{% endmacro %}

View File

@@ -26,6 +26,9 @@
[x-cloak] { display: none !important; }
</style>
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
<body x-cloak>

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) |

View File

@@ -941,7 +941,6 @@ def create_demo_stores(
store_id=store.id,
platform_id=platform_id,
is_active=True,
is_primary=(i == 0),
custom_subdomain=custom_sub,
)
db.add(sp)
@@ -1280,9 +1279,11 @@ def create_demo_store_content_pages(db: Session, stores: list[Store]) -> int:
store_primary_platform: dict[int, int] = {}
sp_rows = db.execute(
select(StorePlatform.store_id, StorePlatform.platform_id)
.where(StorePlatform.is_primary == True) # noqa: E712
.where(StorePlatform.is_active == True) # noqa: E712
.order_by(StorePlatform.joined_at)
).all()
for store_id, platform_id in sp_rows:
if store_id not in store_primary_platform:
store_primary_platform[store_id] = platform_id
# Fallback: OMS platform ID
@@ -1544,7 +1545,7 @@ def print_summary(db: Session):
select(StorePlatform.store_id, Platform.code).join(
Platform, Platform.id == StorePlatform.platform_id
).where(StorePlatform.is_active == True).order_by( # noqa: E712
StorePlatform.store_id, StorePlatform.is_primary.desc()
StorePlatform.store_id, StorePlatform.joined_at
)
).all()
store_platform_map: dict[int, list[str]] = {}
@@ -1563,7 +1564,7 @@ def print_summary(db: Session):
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())
.order_by(StorePlatform.store_id, StorePlatform.joined_at)
).all()
for store_id, pcode, pdomain, custom_sub in sp_detail_rows:
store_platform_details.setdefault(store_id, []).append({

View File

@@ -74,7 +74,7 @@ def get_store_platform_map(db):
"FROM store_platforms sp "
"JOIN platforms p ON p.id = sp.platform_id "
"WHERE sp.is_active = true "
"ORDER BY sp.store_id, sp.is_primary DESC"
"ORDER BY sp.store_id, sp.joined_at"
)
).fetchall()

View File

@@ -0,0 +1,781 @@
// static/shared/js/dev-toolbar.js
/**
* Dev-Mode Debug Toolbar
*
* Full-featured debug toolbar for multi-tenant app development.
* Bottom-docked, resizable panel with 4 tabs:
* - Platform: context from all sources (migrated from platform-diag.js)
* - API Calls: live log of intercepted fetch requests
* - Request Info: current page metadata and globals
* - Console: captured console.log/warn/error/info
*
* Toggle: Ctrl+Alt+D
* Only loads on localhost — auto-hidden in production.
* Theme: Catppuccin Mocha
*/
(function () {
'use strict';
// ── Localhost guard ──
var host = window.location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1') return;
// ── Constants ──
var STORAGE_HEIGHT_KEY = '_dev_toolbar_height';
var STORAGE_TAB_KEY = '_dev_toolbar_tab';
var DEFAULT_HEIGHT = 320;
var MIN_HEIGHT = 150;
var MAX_HEIGHT_RATIO = 0.8;
var MAX_API_CALLS = 200;
var MAX_CONSOLE_LOGS = 500;
var TRUNCATE_LIMIT = 2048;
// Catppuccin Mocha palette
var C = {
base: '#1e1e2e',
surface0: '#313244',
surface1: '#45475a',
text: '#cdd6f4',
subtext: '#a6adc8',
blue: '#89b4fa',
mauve: '#cba6f7',
green: '#a6e3a1',
red: '#f38ba8',
peach: '#fab387',
yellow: '#f9e2af',
teal: '#94e2d5',
sky: '#89dceb',
};
// ── State ──
var apiCalls = [];
var consoleLogs = [];
var activeTab = localStorage.getItem(STORAGE_TAB_KEY) || 'platform';
var panelHeight = parseInt(localStorage.getItem(STORAGE_HEIGHT_KEY), 10) || DEFAULT_HEIGHT;
var toolbarEl = null;
var contentEl = null;
var isExpanded = false;
var consoleFilter = 'all';
var expandedApiRows = {};
// ── Utilities ──
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function truncate(str, limit) {
if (typeof str !== 'string') {
try { str = JSON.stringify(str); } catch (e) { str = String(str); }
}
if (str.length > limit) return str.slice(0, limit) + '\u2026 [truncated]';
return str;
}
function decodeJwtPayload(token) {
try {
var parts = token.split('.');
if (parts.length !== 3) return null;
var payload = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(payload);
} catch (e) {
return null;
}
}
function formatDuration(ms) {
if (ms < 1000) return ms + 'ms';
return (ms / 1000).toFixed(1) + 's';
}
function formatTime(ts) {
var d = new Date(ts);
return d.toLocaleTimeString(undefined, { hour12: false }) + '.' +
String(d.getMilliseconds()).padStart(3, '0');
}
function highlightPlatform(value) {
if (value === 'oms') return C.green;
if (value === 'loyalty') return C.mauve;
if (value === 'hosting') return C.sky;
if (value === 'main') return C.peach;
if (value === '(none)' || value === '(empty)' || value === '(undefined)' ||
value === '(null — Source 3 fallback)') return C.red;
return C.yellow;
}
function statusColor(status) {
if (status === 'ERR') return C.red;
if (status >= 200 && status < 300) return C.green;
if (status >= 300 && status < 400) return C.blue;
if (status >= 400 && status < 500) return C.yellow;
if (status >= 500) return C.red;
return C.subtext;
}
function levelColor(level) {
if (level === 'error') return C.red;
if (level === 'warn') return C.yellow;
if (level === 'info') return C.blue;
return C.subtext;
}
function levelBadge(level) {
return '<span style="display:inline-block;padding:0 4px;border-radius:3px;font-size:9px;font-weight:bold;' +
'background:' + levelColor(level) + ';color:' + C.base + '">' + level.toUpperCase() + '</span>';
}
// ── HTML Helpers ──
function sectionHeader(title) {
return '<div style="margin-top:10px;margin-bottom:4px;color:' + C.blue +
';font-weight:bold;font-size:11px;border-bottom:1px solid ' + C.surface0 +
';padding-bottom:2px">' + escapeHtml(title) + '</div>';
}
function row(label, value, highlightFn) {
var color = highlightFn ? highlightFn(value) : C.text;
return '<div style="display:flex;justify-content:space-between;padding:1px 0">' +
'<span style="color:' + C.subtext + '">' + escapeHtml(label) + '</span>' +
'<span style="color:' + color + ';font-weight:bold">' + escapeHtml(String(value)) + '</span></div>';
}
// ── Interceptors (installed immediately at parse time) ──
// Fetch interceptor
var _originalFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var entry = {
id: apiCalls.length,
timestamp: Date.now(),
method: (init.method || 'GET').toUpperCase(),
url: typeof input === 'string' ? input : (input && input.url ? input.url : String(input)),
requestBody: init.body ? truncate(init.body, TRUNCATE_LIMIT) : null,
requestHeaders: null,
status: null,
duration: null,
responseBody: null,
error: null,
};
// Capture request headers
if (init.headers) {
try {
if (init.headers instanceof Headers) {
var h = {};
init.headers.forEach(function (v, k) { h[k] = v; });
entry.requestHeaders = h;
} else {
entry.requestHeaders = Object.assign({}, init.headers);
}
} catch (e) { /* ignore */ }
}
apiCalls.push(entry);
if (apiCalls.length > MAX_API_CALLS) apiCalls.shift();
var start = performance.now();
return _originalFetch.call(this, input, init).then(function (response) {
entry.status = response.status;
entry.duration = Math.round(performance.now() - start);
var clone = response.clone();
clone.text().then(function (text) {
entry.responseBody = truncate(text, TRUNCATE_LIMIT);
refreshIfActive('api');
}).catch(function () {});
refreshIfActive('api');
return response;
}).catch(function (err) {
entry.status = 'ERR';
entry.duration = Math.round(performance.now() - start);
entry.error = err.message;
refreshIfActive('api');
throw err;
});
};
// Console interceptor
var _origConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
['log', 'info', 'warn', 'error'].forEach(function (level) {
console[level] = function () {
var args = Array.prototype.slice.call(arguments);
consoleLogs.push({
timestamp: Date.now(),
level: level,
args: args.map(function (a) {
if (typeof a === 'object') {
try { return JSON.stringify(a, null, 2); } catch (e) { return String(a); }
}
return String(a);
}),
});
if (consoleLogs.length > MAX_CONSOLE_LOGS) consoleLogs.shift();
refreshIfActive('console');
_origConsole[level].apply(console, args);
};
});
// ── Refresh helper ──
function refreshIfActive(tab) {
if (isExpanded && activeTab === tab && contentEl) {
renderTab();
// Also update tab badges
updateBadges();
}
}
// ── Data Collectors ──
function gatherPlatformContext() {
var storeToken = localStorage.getItem('store_token');
var jwt = storeToken ? decodeJwtPayload(storeToken) : null;
return {
windowPlatformCode: window.STORE_PLATFORM_CODE,
windowStoreCode: window.STORE_CODE,
lsPlatform: localStorage.getItem('store_platform') || '',
lsStoreCode: localStorage.getItem('storeCode') || '',
lsToken: storeToken,
jwt: jwt,
pathname: window.location.pathname,
host: window.location.host,
loginWouldSend: window.STORE_PLATFORM_CODE || localStorage.getItem('store_platform') || null,
};
}
function fetchAuthMe() {
var token = localStorage.getItem('store_token');
if (!token) return Promise.resolve({ error: 'No store_token in localStorage' });
return _originalFetch.call(window, '/api/v1/store/auth/me', {
headers: { 'Authorization': 'Bearer ' + token },
}).then(function (resp) {
if (!resp.ok) return { error: resp.status + ' ' + resp.statusText };
return resp.json();
}).catch(function (e) {
return { error: e.message };
});
}
function gatherRequestInfo() {
return {
url: window.location.href,
pathname: window.location.pathname,
host: window.location.host,
protocol: window.location.protocol,
storeCode: window.STORE_CODE || '(undefined)',
storeConfig: window.STORE_CONFIG || null,
userPermissions: window.USER_PERMISSIONS || null,
storePlatformCode: window.STORE_PLATFORM_CODE || '(undefined)',
lsPlatform: localStorage.getItem('store_platform') || '(empty)',
logConfig: window.LogConfig || null,
detectedFrontend: detectFrontend(),
environment: 'localhost',
tokensPresent: {
store_token: !!localStorage.getItem('store_token'),
admin_token: !!localStorage.getItem('admin_token'),
},
};
}
function detectFrontend() {
var path = window.location.pathname;
if (path.startsWith('/store/') || path === '/store') return 'store';
if (path.startsWith('/admin/') || path === '/admin') return 'admin';
if (path.startsWith('/api/')) return 'api';
return 'unknown';
}
// ── UI Builder ──
function createToolbar() {
// Collapse bar (always visible at bottom)
var collapseBar = document.createElement('div');
collapseBar.id = '_dev_toolbar_collapse';
Object.assign(collapseBar.style, {
position: 'fixed', bottom: '0', left: '0', right: '0', zIndex: '99999',
height: '4px', background: C.mauve, cursor: 'pointer', transition: 'height 0.15s',
});
collapseBar.title = 'Dev Toolbar (Ctrl+Alt+D)';
collapseBar.addEventListener('mouseenter', function () { collapseBar.style.height = '8px'; });
collapseBar.addEventListener('mouseleave', function () {
if (!isExpanded) collapseBar.style.height = '4px';
});
collapseBar.addEventListener('click', toggleToolbar);
document.body.appendChild(collapseBar);
// Main toolbar panel
toolbarEl = document.createElement('div');
toolbarEl.id = '_dev_toolbar';
Object.assign(toolbarEl.style, {
position: 'fixed', bottom: '0', left: '0', right: '0', zIndex: '99998',
height: panelHeight + 'px', background: C.base, color: C.text,
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
fontSize: '11px', lineHeight: '1.5',
borderTop: '2px solid ' + C.mauve, display: 'none',
flexDirection: 'column',
});
// Drag handle
var dragHandle = document.createElement('div');
Object.assign(dragHandle.style, {
height: '6px', cursor: 'ns-resize', background: C.surface0,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0',
});
dragHandle.innerHTML = '<div style="width:40px;height:2px;background:' + C.surface1 +
';border-radius:1px"></div>';
setupDragResize(dragHandle);
toolbarEl.appendChild(dragHandle);
// Tab bar
var tabBar = document.createElement('div');
tabBar.id = '_dev_toolbar_tabs';
Object.assign(tabBar.style, {
display: 'flex', alignItems: 'center', background: C.surface0,
borderBottom: '1px solid ' + C.surface1, flexShrink: '0', padding: '0 8px',
});
var tabs = [
{ id: 'platform', label: 'Platform' },
{ id: 'api', label: 'API' },
{ id: 'request', label: 'Request' },
{ id: 'console', label: 'Console' },
];
tabs.forEach(function (tab) {
var btn = document.createElement('button');
btn.id = '_dev_tab_' + tab.id;
btn.dataset.tab = tab.id;
Object.assign(btn.style, {
padding: '4px 12px', border: 'none', cursor: 'pointer',
fontSize: '11px', fontFamily: 'inherit', background: 'transparent',
color: C.subtext, borderBottom: '2px solid transparent', transition: 'all 0.15s',
});
btn.innerHTML = tab.label + '<span id="_dev_badge_' + tab.id + '" style="margin-left:4px;font-size:9px;color:' + C.subtext + '"></span>';
btn.addEventListener('click', function () { switchTab(tab.id); });
tabBar.appendChild(btn);
});
// Close button (right-aligned)
var spacer = document.createElement('div');
spacer.style.flex = '1';
tabBar.appendChild(spacer);
var closeBtn = document.createElement('button');
Object.assign(closeBtn.style, {
padding: '2px 8px', border: 'none', cursor: 'pointer', fontSize: '11px',
fontFamily: 'inherit', background: C.red, color: C.base,
borderRadius: '3px', fontWeight: 'bold', margin: '2px 0',
});
closeBtn.textContent = '\u2715 Close';
closeBtn.addEventListener('click', toggleToolbar);
tabBar.appendChild(closeBtn);
toolbarEl.appendChild(tabBar);
// Content area
contentEl = document.createElement('div');
contentEl.id = '_dev_toolbar_content';
Object.assign(contentEl.style, {
flex: '1', overflowY: 'auto', padding: '8px 12px',
});
toolbarEl.appendChild(contentEl);
document.body.appendChild(toolbarEl);
updateTabStyles();
updateBadges();
}
function setupDragResize(handle) {
var startY, startHeight;
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
startY = e.clientY;
startHeight = panelHeight;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', onDragEnd);
document.body.style.userSelect = 'none';
});
function onDrag(e) {
var delta = startY - e.clientY;
var newHeight = Math.max(MIN_HEIGHT, Math.min(window.innerHeight * MAX_HEIGHT_RATIO, startHeight + delta));
panelHeight = Math.round(newHeight);
if (toolbarEl) toolbarEl.style.height = panelHeight + 'px';
}
function onDragEnd() {
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', onDragEnd);
document.body.style.userSelect = '';
localStorage.setItem(STORAGE_HEIGHT_KEY, String(panelHeight));
}
}
function toggleToolbar() {
if (!toolbarEl) createToolbar();
isExpanded = !isExpanded;
toolbarEl.style.display = isExpanded ? 'flex' : 'none';
if (isExpanded) {
renderTab();
updateBadges();
}
}
function switchTab(tabId) {
activeTab = tabId;
localStorage.setItem(STORAGE_TAB_KEY, tabId);
updateTabStyles();
renderTab();
}
function updateTabStyles() {
var tabs = ['platform', 'api', 'request', 'console'];
tabs.forEach(function (id) {
var btn = document.getElementById('_dev_tab_' + id);
if (!btn) return;
if (id === activeTab) {
btn.style.color = C.mauve;
btn.style.borderBottomColor = C.mauve;
btn.style.background = C.base;
} else {
btn.style.color = C.subtext;
btn.style.borderBottomColor = 'transparent';
btn.style.background = 'transparent';
}
});
}
function updateBadges() {
var apiBadge = document.getElementById('_dev_badge_api');
if (apiBadge) {
apiBadge.textContent = apiCalls.length > 0 ? '(' + apiCalls.length + ')' : '';
}
var consoleBadge = document.getElementById('_dev_badge_console');
if (consoleBadge) {
var errCount = consoleLogs.filter(function (l) { return l.level === 'error'; }).length;
var warnCount = consoleLogs.filter(function (l) { return l.level === 'warn'; }).length;
var parts = [];
if (errCount > 0) parts.push('<span style="color:' + C.red + '">' + errCount + 'E</span>');
if (warnCount > 0) parts.push('<span style="color:' + C.yellow + '">' + warnCount + 'W</span>');
if (parts.length === 0 && consoleLogs.length > 0) parts.push(consoleLogs.length.toString());
consoleBadge.innerHTML = parts.length > 0 ? '(' + parts.join('/') + ')' : '';
}
}
// ── Tab Renderers ──
function renderTab() {
if (!contentEl) return;
switch (activeTab) {
case 'platform': renderPlatformTab(); break;
case 'api': renderApiCallsTab(); break;
case 'request': renderRequestInfoTab(); break;
case 'console': renderConsoleTab(); break;
}
}
function renderPlatformTab() {
var ctx = gatherPlatformContext();
var jwt = ctx.jwt;
var html = '';
html += sectionHeader('Client State');
html += row('window.STORE_PLATFORM_CODE', ctx.windowPlatformCode ?? '(undefined)', highlightPlatform);
html += row('window.STORE_CODE', ctx.windowStoreCode ?? '(undefined)');
html += row('localStorage.store_platform', ctx.lsPlatform || '(empty)', highlightPlatform);
html += row('localStorage.storeCode', ctx.lsStoreCode || '(empty)');
html += row('localStorage.store_token', ctx.lsToken ? '...' + ctx.lsToken.slice(-20) : '(empty)');
html += sectionHeader('JWT Token (decoded)');
html += row('platform_code', jwt?.platform_code ?? '(none)', highlightPlatform);
html += row('platform_id', jwt?.platform_id ?? '(none)');
html += row('store_code', jwt?.store_code ?? '(none)');
html += row('store_role', jwt?.store_role ?? '(none)');
html += row('username', jwt?.username ?? '(none)');
html += row('expires', jwt?.exp ? new Date(jwt.exp * 1000).toLocaleTimeString() : '(none)');
html += sectionHeader('Login Would Send');
var loginVal = ctx.loginWouldSend || '(null \u2014 Source 3 fallback)';
html += row('platform_code', loginVal, highlightPlatform);
html += '<div style="color:' + C.subtext + ';margin-top:2px;font-size:10px">' +
'STORE_PLATFORM_CODE || localStorage.store_platform || null</div>';
html += sectionHeader('URL');
html += row('host', ctx.host);
html += row('pathname', ctx.pathname);
// Consistency check
html += sectionHeader('Consistency Check');
var jwtPlatform = jwt?.platform_code ?? '(none)';
var lsPlatform = ctx.lsPlatform || '(empty)';
var windowPlatform = ctx.windowPlatformCode;
if (jwtPlatform !== '(none)' && lsPlatform !== '(empty)' && jwtPlatform !== lsPlatform) {
html += '<div style="color:' + C.red + ';font-weight:bold">MISMATCH: JWT platform_code=' +
escapeHtml(jwtPlatform) + ' but localStorage.store_platform=' + escapeHtml(lsPlatform) + '</div>';
} else if (jwtPlatform !== '(none)' && windowPlatform !== undefined && windowPlatform !== null &&
String(windowPlatform) !== '(undefined)' && jwtPlatform !== windowPlatform) {
html += '<div style="color:' + C.peach + ';font-weight:bold">WARNING: JWT platform_code=' +
escapeHtml(jwtPlatform) + ' but window.STORE_PLATFORM_CODE=' + escapeHtml(String(windowPlatform)) + '</div>';
} else {
html += '<div style="color:' + C.green + '">All sources consistent</div>';
}
contentEl.innerHTML = html;
// Async /auth/me
fetchAuthMe().then(function (me) {
var meHtml = sectionHeader('/auth/me (server)');
if (me.error) {
meHtml += '<div style="color:' + C.red + '">' + escapeHtml(me.error) + '</div>';
} else {
meHtml += row('platform_code', me.platform_code ?? '(null)', highlightPlatform);
meHtml += row('username', me.username || '(unknown)');
meHtml += row('role', me.role || '(unknown)');
if (me.platform_code && jwtPlatform !== '(none)' && me.platform_code !== jwtPlatform) {
meHtml += '<div style="color:' + C.red + ';font-weight:bold">MISMATCH: /me platform_code=' +
escapeHtml(me.platform_code) + ' but JWT=' + escapeHtml(jwtPlatform) + '</div>';
}
}
if (contentEl && activeTab === 'platform') {
contentEl.innerHTML += meHtml;
}
});
}
function renderApiCallsTab() {
var html = '';
// Toolbar row
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
html += '<span style="color:' + C.subtext + '">' + apiCalls.length + ' requests captured</span>';
html += '<button id="_dev_api_clear" style="padding:2px 8px;font-size:10px;border-radius:3px;' +
'background:' + C.surface1 + ';color:' + C.text + ';border:none;cursor:pointer;font-family:inherit">Clear</button>';
html += '</div>';
if (apiCalls.length === 0) {
html += '<div style="color:' + C.subtext + ';text-align:center;padding:20px">No API calls captured yet.</div>';
} else {
// Table header
html += '<div style="display:flex;padding:2px 4px;color:' + C.subtext + ';font-size:10px;font-weight:bold;' +
'border-bottom:1px solid ' + C.surface1 + '">';
html += '<span style="width:50px">Method</span>';
html += '<span style="flex:1;padding:0 4px">URL</span>';
html += '<span style="width:50px;text-align:right">Status</span>';
html += '<span style="width:60px;text-align:right">Time</span>';
html += '<span style="width:70px;text-align:right">When</span>';
html += '</div>';
// Rows (newest first)
for (var i = apiCalls.length - 1; i >= 0; i--) {
var call = apiCalls[i];
var isExpanded_ = expandedApiRows[call.id];
var methodColor = call.method === 'GET' ? C.green : call.method === 'POST' ? C.blue :
call.method === 'PUT' ? C.peach : call.method === 'DELETE' ? C.red : C.text;
html += '<div style="border-bottom:1px solid ' + C.surface0 + ';cursor:pointer" data-api-row="' + call.id + '">';
html += '<div style="display:flex;padding:2px 4px;align-items:center" data-api-toggle="' + call.id + '">';
html += '<span style="width:50px;color:' + methodColor + ';font-weight:bold;font-size:10px">' + call.method + '</span>';
html += '<span style="flex:1;padding:0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(call.url) + '</span>';
html += '<span style="width:50px;text-align:right;color:' + statusColor(call.status) + ';font-weight:bold">' +
(call.status !== null ? call.status : '\u2022\u2022\u2022') + '</span>';
html += '<span style="width:60px;text-align:right">' +
(call.duration !== null ? formatDuration(call.duration) : '\u2022\u2022\u2022') + '</span>';
html += '<span style="width:70px;text-align:right;color:' + C.subtext + ';font-size:10px">' + formatTime(call.timestamp) + '</span>';
html += '</div>';
// Expanded detail
if (isExpanded_) {
html += '<div style="padding:4px 8px 8px;background:' + C.surface0 + ';font-size:10px">';
if (call.requestHeaders) {
html += '<div style="color:' + C.blue + ';font-weight:bold;margin-top:4px">Request Headers</div>';
html += '<pre style="margin:2px 0;white-space:pre-wrap;color:' + C.text + '">' +
escapeHtml(JSON.stringify(call.requestHeaders, null, 2)) + '</pre>';
}
if (call.requestBody) {
html += '<div style="color:' + C.blue + ';font-weight:bold;margin-top:4px">Request Body</div>';
html += '<pre style="margin:2px 0;white-space:pre-wrap;color:' + C.text + '">' + escapeHtml(formatJsonSafe(call.requestBody)) + '</pre>';
}
if (call.responseBody) {
html += '<div style="color:' + C.green + ';font-weight:bold;margin-top:4px">Response Body</div>';
html += '<pre style="margin:2px 0;white-space:pre-wrap;color:' + C.text + ';max-height:200px;overflow-y:auto">' +
escapeHtml(formatJsonSafe(call.responseBody)) + '</pre>';
}
if (call.error) {
html += '<div style="color:' + C.red + ';font-weight:bold;margin-top:4px">Error: ' + escapeHtml(call.error) + '</div>';
}
html += '</div>';
}
html += '</div>';
}
}
contentEl.innerHTML = html;
// Attach event listeners
var clearBtn = document.getElementById('_dev_api_clear');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
apiCalls.length = 0;
expandedApiRows = {};
renderApiCallsTab();
updateBadges();
});
}
contentEl.querySelectorAll('[data-api-toggle]').forEach(function (el) {
el.addEventListener('click', function () {
var id = parseInt(el.dataset.apiToggle, 10);
expandedApiRows[id] = !expandedApiRows[id];
renderApiCallsTab();
});
});
}
function formatJsonSafe(str) {
try {
var obj = JSON.parse(str);
return JSON.stringify(obj, null, 2);
} catch (e) {
return str;
}
}
function renderRequestInfoTab() {
var info = gatherRequestInfo();
var html = '';
html += sectionHeader('Page');
html += row('URL', info.url);
html += row('pathname', info.pathname);
html += row('host', info.host);
html += row('protocol', info.protocol);
html += row('detected frontend', info.detectedFrontend);
html += row('environment', info.environment);
html += sectionHeader('Platform Context');
html += row('STORE_CODE', info.storeCode);
html += row('STORE_PLATFORM_CODE', info.storePlatformCode, highlightPlatform);
html += row('localStorage.store_platform', info.lsPlatform, highlightPlatform);
html += sectionHeader('Store Config');
if (info.storeConfig) {
Object.keys(info.storeConfig).forEach(function (key) {
html += row(key, info.storeConfig[key] ?? '(null)');
});
} else {
html += '<div style="color:' + C.subtext + '">STORE_CONFIG not defined</div>';
}
html += sectionHeader('User Permissions');
if (info.userPermissions && Array.isArray(info.userPermissions)) {
if (info.userPermissions.length === 0) {
html += '<div style="color:' + C.subtext + '">(empty array)</div>';
} else {
info.userPermissions.forEach(function (perm) {
html += '<div style="padding:1px 0;color:' + C.green + '">\u2022 ' + escapeHtml(String(perm)) + '</div>';
});
}
} else {
html += '<div style="color:' + C.subtext + '">USER_PERMISSIONS not defined</div>';
}
html += sectionHeader('Tokens');
html += row('store_token', info.tokensPresent.store_token ? 'present' : 'absent');
html += row('admin_token', info.tokensPresent.admin_token ? 'present' : 'absent');
html += sectionHeader('Log Config');
if (info.logConfig) {
Object.keys(info.logConfig).forEach(function (key) {
var val = info.logConfig[key];
if (typeof val !== 'function') {
html += row(key, String(val));
}
});
} else {
html += '<div style="color:' + C.subtext + '">LogConfig not defined</div>';
}
contentEl.innerHTML = html;
}
function renderConsoleTab() {
var html = '';
// Filter bar
html += '<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px">';
var filters = ['all', 'log', 'info', 'warn', 'error'];
filters.forEach(function (f) {
var isActive = consoleFilter === f;
var bg = isActive ? C.mauve : C.surface1;
var fg = isActive ? C.base : C.text;
html += '<button class="_dev_console_filter" data-filter="' + f + '" style="padding:2px 8px;font-size:10px;' +
'border-radius:3px;background:' + bg + ';color:' + fg + ';border:none;cursor:pointer;font-family:inherit;font-weight:bold">' +
f.toUpperCase() + '</button>';
});
html += '<div style="flex:1"></div>';
html += '<button id="_dev_console_clear" style="padding:2px 8px;font-size:10px;border-radius:3px;' +
'background:' + C.surface1 + ';color:' + C.text + ';border:none;cursor:pointer;font-family:inherit">Clear</button>';
html += '</div>';
// Filtered logs
var filtered = consoleLogs;
if (consoleFilter !== 'all') {
filtered = consoleLogs.filter(function (l) { return l.level === consoleFilter; });
}
if (filtered.length === 0) {
html += '<div style="color:' + C.subtext + ';text-align:center;padding:20px">No console output captured.</div>';
} else {
for (var i = filtered.length - 1; i >= 0; i--) {
var entry = filtered[i];
html += '<div style="display:flex;gap:6px;padding:2px 0;border-bottom:1px solid ' + C.surface0 + ';align-items:flex-start">';
html += '<span style="flex-shrink:0;color:' + C.subtext + ';font-size:10px;width:75px">' + formatTime(entry.timestamp) + '</span>';
html += '<span style="flex-shrink:0">' + levelBadge(entry.level) + '</span>';
html += '<span style="flex:1;white-space:pre-wrap;word-break:break-all;color:' + levelColor(entry.level) + '">';
html += escapeHtml(entry.args.join(' '));
html += '</span></div>';
}
}
contentEl.innerHTML = html;
// Attach filter listeners
contentEl.querySelectorAll('._dev_console_filter').forEach(function (btn) {
btn.addEventListener('click', function () {
consoleFilter = btn.dataset.filter;
renderConsoleTab();
});
});
var clearBtn = document.getElementById('_dev_console_clear');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
consoleLogs.length = 0;
renderConsoleTab();
updateBadges();
});
}
}
// ── Keyboard shortcut: Ctrl+Shift+D ──
document.addEventListener('keydown', function (e) {
if (e.ctrlKey && e.altKey && (e.key === 'D' || e.key === 'd')) {
e.preventDefault();
toggleToolbar();
}
});
// ── Init on DOMContentLoaded ──
function init() {
createToolbar();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();