feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- 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:
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
35
alembic/versions/remove_store_platform_is_primary.py
Normal 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"]
|
||||
)
|
||||
@@ -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])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
34
app/modules/dev_tools/models/saved_query.py
Normal file
34
app/modules/dev_tools/models/saved_query.py
Normal 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,
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
17
app/modules/dev_tools/routes/api/admin.py
Normal file
17
app/modules/dev_tools/routes/api/admin.py
Normal 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"])
|
||||
344
app/modules/dev_tools/routes/api/admin_platform_debug.py
Normal file
344
app/modules/dev_tools/routes/api/admin_platform_debug.py
Normal 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("_")}
|
||||
158
app/modules/dev_tools/routes/api/admin_sql_query.py
Normal file
158
app/modules/dev_tools/routes/api/admin_sql_query.py
Normal 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}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
197
app/modules/dev_tools/services/sql_query_service.py
Normal file
197
app/modules/dev_tools/services/sql_query_service.py
Normal 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()
|
||||
249
app/modules/dev_tools/static/admin/js/sql-query.js
Normal file
249
app/modules/dev_tools/static/admin/js/sql-query.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 %}
|
||||
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal file
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("#")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
182
app/modules/loyalty/static/store/js/loyalty-settings.js
Normal file
182
app/modules/loyalty/static/store/js/loyalty-settings.js
Normal 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');
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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">
|
||||
|
||||
246
app/modules/loyalty/templates/loyalty/store/settings.html
Normal file
246
app/modules/loyalty/templates/loyalty/store/settings.html
Normal 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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
0
app/modules/loyalty/tests/unit/__init__.py
Normal file
0
app/modules/loyalty/tests/unit/__init__.py
Normal 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
173
docs/proposals/store-menu-multi-platform-visibility.md
Normal file
173
docs/proposals/store-menu-multi-platform-visibility.md
Normal 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) |
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
781
static/shared/js/dev-toolbar.js
Normal file
781
static/shared/js/dev-toolbar.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user