From 319900623ac6503261d6b45e4f39b28c54a25817 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 10 Mar 2026 20:08:07 +0100 Subject: [PATCH] feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements - 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 --- .../remove_store_platform_is_primary.py | 35 + .../billing/dependencies/feature_gate.py | 18 +- .../versions/billing_001_initial.py | 4 +- app/modules/billing/routes/api/store.py | 6 +- .../billing/routes/api/store_checkout.py | 12 +- .../billing/routes/api/store_features.py | 12 +- .../billing/services/feature_service.py | 33 +- .../services/store_platform_sync_service.py | 2 +- .../billing/services/stripe_service.py | 5 +- .../billing/services/subscription_service.py | 14 +- .../unit/test_store_platform_sync_service.py | 48 -- .../cms/routes/api/store_content_pages.py | 4 +- .../cms/services/content_page_service.py | 4 +- .../core/routes/api/store_dashboard.py | 4 +- app/modules/core/routes/api/store_menu.py | 2 +- app/modules/core/services/menu_service.py | 14 +- .../core/services/onboarding_aggregator.py | 22 +- app/modules/core/static/store/js/dashboard.js | 2 + app/modules/dev_tools/definition.py | 21 +- app/modules/dev_tools/locales/de.json | 19 +- app/modules/dev_tools/locales/en.json | 19 +- app/modules/dev_tools/locales/fr.json | 19 +- app/modules/dev_tools/locales/lb.json | 19 +- .../versions/dev_tools_002_saved_queries.py | 47 ++ app/modules/dev_tools/models/__init__.py | 3 + app/modules/dev_tools/models/saved_query.py | 34 + app/modules/dev_tools/routes/api/__init__.py | 8 +- app/modules/dev_tools/routes/api/admin.py | 17 + .../routes/api/admin_platform_debug.py | 344 ++++++++ .../dev_tools/routes/api/admin_sql_query.py | 158 ++++ app/modules/dev_tools/routes/pages/admin.py | 36 + .../dev_tools/services/sql_query_service.py | 197 +++++ .../dev_tools/static/admin/js/sql-query.js | 249 ++++++ .../dev_tools/admin/platform-debug.html | 466 +++++++++++ .../templates/dev_tools/admin/sql-query.html | 205 +++++ app/modules/loyalty/definition.py | 6 +- .../loyalty/models/merchant_settings.py | 4 +- app/modules/loyalty/routes/api/platform.py | 12 +- app/modules/loyalty/routes/api/store.py | 49 ++ app/modules/loyalty/routes/pages/store.py | 26 + app/modules/loyalty/schemas/program.py | 8 + .../loyalty/services/apple_wallet_service.py | 110 ++- .../loyalty/services/loyalty_features.py | 82 +- .../loyalty/services/loyalty_onboarding.py | 10 +- app/modules/loyalty/services/pin_service.py | 8 +- .../loyalty/services/program_service.py | 83 ++ .../loyalty/static/store/js/loyalty-cards.js | 9 +- .../static/store/js/loyalty-settings.js | 182 ++++ .../loyalty/static/store/js/loyalty-stats.js | 16 +- .../static/store/js/loyalty-terminal.js | 30 +- .../templates/loyalty/store/cards.html | 26 +- .../templates/loyalty/store/settings.html | 246 ++++++ .../templates/loyalty/store/stats.html | 22 +- .../templates/loyalty/store/terminal.html | 173 ++-- .../tests/integration/test_store_api.py | 248 +++++- app/modules/loyalty/tests/unit/__init__.py | 0 .../loyalty/tests/unit/test_pin_service.py | 217 +++++ .../loyalty/tests/unit/test_points_service.py | 356 ++++++++ .../tests/unit/test_program_service.py | 95 +++ .../loyalty/tests/unit/test_stamp_service.py | 269 ++++++ app/modules/tenancy/models/store_platform.py | 12 - app/modules/tenancy/routes/api/store_auth.py | 60 +- app/modules/tenancy/routes/pages/store.py | 30 +- app/modules/tenancy/schemas/auth.py | 4 + .../services/merchant_domain_service.py | 3 +- .../tenancy/services/platform_service.py | 46 +- .../tenancy/services/store_domain_service.py | 11 +- app/modules/tenancy/static/store/js/login.js | 26 +- .../templates/tenancy/store/login.html | 11 +- app/templates/shared/macros/feature_gate.html | 84 +- app/templates/store/base.html | 3 + docs/architecture/middleware.md | 46 ++ .../store-login-platform-detection.md | 50 +- .../store-menu-multi-platform-visibility.md | 173 ++++ scripts/seed/seed_demo.py | 11 +- scripts/show_urls.py | 2 +- static/shared/js/dev-toolbar.js | 781 ++++++++++++++++++ 77 files changed, 5341 insertions(+), 401 deletions(-) create mode 100644 alembic/versions/remove_store_platform_is_primary.py create mode 100644 app/modules/dev_tools/migrations/versions/dev_tools_002_saved_queries.py create mode 100644 app/modules/dev_tools/models/saved_query.py create mode 100644 app/modules/dev_tools/routes/api/admin.py create mode 100644 app/modules/dev_tools/routes/api/admin_platform_debug.py create mode 100644 app/modules/dev_tools/routes/api/admin_sql_query.py create mode 100644 app/modules/dev_tools/services/sql_query_service.py create mode 100644 app/modules/dev_tools/static/admin/js/sql-query.js create mode 100644 app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html create mode 100644 app/modules/dev_tools/templates/dev_tools/admin/sql-query.html create mode 100644 app/modules/loyalty/static/store/js/loyalty-settings.js create mode 100644 app/modules/loyalty/templates/loyalty/store/settings.html create mode 100644 app/modules/loyalty/tests/unit/__init__.py create mode 100644 docs/proposals/store-menu-multi-platform-visibility.md create mode 100644 static/shared/js/dev-toolbar.js diff --git a/alembic/versions/remove_store_platform_is_primary.py b/alembic/versions/remove_store_platform_is_primary.py new file mode 100644 index 00000000..ad85ec4e --- /dev/null +++ b/alembic/versions/remove_store_platform_is_primary.py @@ -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"] + ) diff --git a/app/modules/billing/dependencies/feature_gate.py b/app/modules/billing/dependencies/feature_gate.py index 20750a62..9328b550 100644 --- a/app/modules/billing/dependencies/feature_gate.py +++ b/app/modules/billing/dependencies/feature_gate.py @@ -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]) diff --git a/app/modules/billing/migrations/versions/billing_001_initial.py b/app/modules/billing/migrations/versions/billing_001_initial.py index c1ef20aa..4bcdf020 100644 --- a/app/modules/billing/migrations/versions/billing_001_initial.py +++ b/app/modules/billing/migrations/versions/billing_001_initial.py @@ -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( diff --git a/app/modules/billing/routes/api/store.py b/app/modules/billing/routes/api/store.py index 16b19c1c..9cda29d3 100644 --- a/app/modules/billing/routes/api/store.py +++ b/app/modules/billing/routes/api/store.py @@ -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) diff --git a/app/modules/billing/routes/api/store_checkout.py b/app/modules/billing/routes/api/store_checkout.py index 90187080..89d1aa2d 100644 --- a/app/modules/billing/routes/api/store_checkout.py +++ b/app/modules/billing/routes/api/store_checkout.py @@ -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, diff --git a/app/modules/billing/routes/api/store_features.py b/app/modules/billing/routes/api/store_features.py index 94c66f88..d7f4d356 100644 --- a/app/modules/billing/routes/api/store_features.py +++ b/app/modules/billing/routes/api/store_features.py @@ -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) diff --git a/app/modules/billing/services/feature_service.py b/app/modules/billing/services/feature_service.py index c6f2697b..100571cd 100644 --- a/app/modules/billing/services/feature_service.py +++ b/app/modules/billing/services/feature_service.py @@ -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" diff --git a/app/modules/billing/services/store_platform_sync_service.py b/app/modules/billing/services/store_platform_sync_service.py index 5c781476..59a06b9d 100644 --- a/app/modules/billing/services/store_platform_sync_service.py +++ b/app/modules/billing/services/store_platform_sync_service.py @@ -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) diff --git a/app/modules/billing/services/stripe_service.py b/app/modules/billing/services/stripe_service.py index bfe46dc8..76c3e80b 100644 --- a/app/modules/billing/services/stripe_service.py +++ b/app/modules/billing/services/stripe_service.py @@ -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 = ( diff --git a/app/modules/billing/services/subscription_service.py b/app/modules/billing/services/subscription_service.py index fc68d434..60870b2d 100644 --- a/app/modules/billing/services/subscription_service.py +++ b/app/modules/billing/services/subscription_service.py @@ -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 diff --git a/app/modules/billing/tests/unit/test_store_platform_sync_service.py b/app/modules/billing/tests/unit/test_store_platform_sync_service.py index aa66c685..c7951dac 100644 --- a/app/modules/billing/tests/unit/test_store_platform_sync_service.py +++ b/app/modules/billing/tests/unit/test_store_platform_sync_service.py @@ -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() diff --git a/app/modules/cms/routes/api/store_content_pages.py b/app/modules/cms/routes/api/store_content_pages.py index b043de99..a3416e4a 100644 --- a/app/modules/cms/routes/api/store_content_pages.py +++ b/app/modules/cms/routes/api/store_content_pages.py @@ -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, diff --git a/app/modules/cms/services/content_page_service.py b/app/modules/cms/services/content_page_service.py index dec8242d..4757f73b 100644 --- a/app/modules/cms/services/content_page_service.py +++ b/app/modules/cms/services/content_page_service.py @@ -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: diff --git a/app/modules/core/routes/api/store_dashboard.py b/app/modules/core/routes/api/store_dashboard.py index b54e4c5d..5377a6cf 100644 --- a/app/modules/core/routes/api/store_dashboard.py +++ b/app/modules/core/routes/api/store_dashboard.py @@ -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 diff --git a/app/modules/core/routes/api/store_menu.py b/app/modules/core/routes/api/store_menu.py index c25fc5f4..ed3a36ad 100644 --- a/app/modules/core/routes/api/store_menu.py +++ b/app/modules/core/routes/api/store_menu.py @@ -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( diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index a791b3d6..cad7e3f8 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -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, diff --git a/app/modules/core/services/onboarding_aggregator.py b/app/modules/core/services/onboarding_aggregator.py index b8212eaa..7ee2f6ca 100644 --- a/app/modules/core/services/onboarding_aggregator.py +++ b/app/modules/core/services/onboarding_aggregator.py @@ -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,10 +54,10 @@ 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 - store_platform_ids = {platform_id} + # 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]] = [] diff --git a/app/modules/core/static/store/js/dashboard.js b/app/modules/core/static/store/js/dashboard.js index 79bc5997..6ea78984 100644 --- a/app/modules/core/static/store/js/dashboard.js +++ b/app/modules/core/static/store/js/dashboard.js @@ -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 diff --git a/app/modules/dev_tools/definition.py b/app/modules/dev_tools/definition.py index 50a0beaa..ba80eaa6 100644 --- a/app/modules/dev_tools/definition.py +++ b/app/modules/dev_tools/definition.py @@ -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 diff --git a/app/modules/dev_tools/locales/de.json b/app/modules/dev_tools/locales/de.json index 7824aa35..a6b131f8 100644 --- a/app/modules/dev_tools/locales/de.json +++ b/app/modules/dev_tools/locales/de.json @@ -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" } } diff --git a/app/modules/dev_tools/locales/en.json b/app/modules/dev_tools/locales/en.json index 61e89803..5b57ddce 100644 --- a/app/modules/dev_tools/locales/en.json +++ b/app/modules/dev_tools/locales/en.json @@ -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" } } diff --git a/app/modules/dev_tools/locales/fr.json b/app/modules/dev_tools/locales/fr.json index 2a8756f1..83de7046 100644 --- a/app/modules/dev_tools/locales/fr.json +++ b/app/modules/dev_tools/locales/fr.json @@ -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" } } diff --git a/app/modules/dev_tools/locales/lb.json b/app/modules/dev_tools/locales/lb.json index fc1191dc..998bbfaf 100644 --- a/app/modules/dev_tools/locales/lb.json +++ b/app/modules/dev_tools/locales/lb.json @@ -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" } } diff --git a/app/modules/dev_tools/migrations/versions/dev_tools_002_saved_queries.py b/app/modules/dev_tools/migrations/versions/dev_tools_002_saved_queries.py new file mode 100644 index 00000000..e7924a28 --- /dev/null +++ b/app/modules/dev_tools/migrations/versions/dev_tools_002_saved_queries.py @@ -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") diff --git a/app/modules/dev_tools/models/__init__.py b/app/modules/dev_tools/models/__init__.py index 30bc8b3b..0e2fb6cd 100644 --- a/app/modules/dev_tools/models/__init__.py +++ b/app/modules/dev_tools/models/__init__.py @@ -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", ] diff --git a/app/modules/dev_tools/models/saved_query.py b/app/modules/dev_tools/models/saved_query.py new file mode 100644 index 00000000..fd579856 --- /dev/null +++ b/app/modules/dev_tools/models/saved_query.py @@ -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, + ) diff --git a/app/modules/dev_tools/routes/api/__init__.py b/app/modules/dev_tools/routes/api/__init__.py index ca255c38..292f6212 100644 --- a/app/modules/dev_tools/routes/api/__init__.py +++ b/app/modules/dev_tools/routes/api/__init__.py @@ -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"] diff --git a/app/modules/dev_tools/routes/api/admin.py b/app/modules/dev_tools/routes/api/admin.py new file mode 100644 index 00000000..da8f92fc --- /dev/null +++ b/app/modules/dev_tools/routes/api/admin.py @@ -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"]) diff --git a/app/modules/dev_tools/routes/api/admin_platform_debug.py b/app/modules/dev_tools/routes/api/admin_platform_debug.py new file mode 100644 index 00000000..21d052f2 --- /dev/null +++ b/app/modules/dev_tools/routes/api/admin_platform_debug.py @@ -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("_")} diff --git a/app/modules/dev_tools/routes/api/admin_sql_query.py b/app/modules/dev_tools/routes/api/admin_sql_query.py new file mode 100644 index 00000000..f0567182 --- /dev/null +++ b/app/modules/dev_tools/routes/api/admin_sql_query.py @@ -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} diff --git a/app/modules/dev_tools/routes/pages/admin.py b/app/modules/dev_tools/routes/pages/admin.py index 04d4f169..3bb5d209 100644 --- a/app/modules/dev_tools/routes/pages/admin.py +++ b/app/modules/dev_tools/routes/pages/admin.py @@ -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), + ) diff --git a/app/modules/dev_tools/services/sql_query_service.py b/app/modules/dev_tools/services/sql_query_service.py new file mode 100644 index 00000000..bfb50ecf --- /dev/null +++ b/app/modules/dev_tools/services/sql_query_service.py @@ -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"" + 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() diff --git a/app/modules/dev_tools/static/admin/js/sql-query.js b/app/modules/dev_tools/static/admin/js/sql-query.js new file mode 100644 index 00000000..384df98f --- /dev/null +++ b/app/modules/dev_tools/static/admin/js/sql-query.js @@ -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; + }, + }; +} diff --git a/app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html b/app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html new file mode 100644 index 00000000..7e1c6f8e --- /dev/null +++ b/app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html @@ -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 %} +
+

Platform Resolution Trace

+

+ Simulates the middleware pipeline for each URL pattern to trace how platform & store context are resolved. +

+
+ + +
+
+ +
+ + + + + +
+
+
+ + +
+

Custom Test

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + +
+ +
+ + +{% endblock %} diff --git a/app/modules/dev_tools/templates/dev_tools/admin/sql-query.html b/app/modules/dev_tools/templates/dev_tools/admin/sql-query.html new file mode 100644 index 00000000..ad90b920 --- /dev/null +++ b/app/modules/dev_tools/templates/dev_tools/admin/sql-query.html @@ -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') }} + +
+ +
+ +
+ +
+ +
+
+ + +
+

+ + Saved Queries +

+
Loading...
+
+ No saved queries yet. +
+
    + +
+
+
+ + +
+ +
+
+ +
+ + +
+ + + + + +
+
+ + +
+ + rows returned + + + (results truncated to 1000 rows) + + + ms + +
+ + +
+
+ +

+            
+
+ + +
+
+ + + + + + + + + +
+
+
+
+
+ + +{% call modal('saveQueryModal', 'Save Query', show_var='showSaveModal', size='sm', show_footer=false) %} +
+
+ + +
+
+ + +
+
+ + +
+
+{% endcall %} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index c8f1d9aa..d7a6a742 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -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 diff --git a/app/modules/loyalty/models/merchant_settings.py b/app/modules/loyalty/models/merchant_settings.py index 84d374c9..c62e560b 100644 --- a/app/modules/loyalty/models/merchant_settings.py +++ b/app/modules/loyalty/models/merchant_settings.py @@ -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 diff --git a/app/modules/loyalty/routes/api/platform.py b/app/modules/loyalty/routes/api/platform.py index 2c73f67e..7765dcb1 100644 --- a/app/modules/loyalty/routes/api/platform.py +++ b/app/modules/loyalty/routes/api/platform.py @@ -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) diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 5c75fee6..e82a072f 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -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), diff --git a/app/modules/loyalty/routes/pages/store.py b/app/modules/loyalty/routes/pages/store.py index 6e024596..6c62a779 100644 --- a/app/modules/loyalty/routes/pages/store.py +++ b/app/modules/loyalty/routes/pages/store.py @@ -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 # ============================================================================ diff --git a/app/modules/loyalty/schemas/program.py b/app/modules/loyalty/schemas/program.py index 1abe8045..5f4d5224 100644 --- a/app/modules/loyalty/schemas/program.py +++ b/app/modules/loyalty/schemas/program.py @@ -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 diff --git a/app/modules/loyalty/services/apple_wallet_service.py b/app/modules/loyalty/services/apple_wallet_service.py index cf814d72..5071d533 100644 --- a/app/modules/loyalty/services/apple_wallet_service.py +++ b/app/modules/loyalty/services/apple_wallet_service.py @@ -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("#") diff --git a/app/modules/loyalty/services/loyalty_features.py b/app/modules/loyalty/services/loyalty_features.py index 38f66c6d..b0c0c06f 100644 --- a/app/modules/loyalty/services/loyalty_features.py +++ b/app/modules/loyalty/services/loyalty_features.py @@ -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 diff --git a/app/modules/loyalty/services/loyalty_onboarding.py b/app/modules/loyalty/services/loyalty_onboarding.py index c6099776..cba46dff 100644 --- a/app/modules/loyalty/services/loyalty_onboarding.py +++ b/app/modules/loyalty/services/loyalty_onboarding.py @@ -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() diff --git a/app/modules/loyalty/services/pin_service.py b/app/modules/loyalty/services/pin_service.py index 1e4f87fb..7715ed57 100644 --- a/app/modules/loyalty/services/pin_service.py +++ b/app/modules/loyalty/services/pin_service.py @@ -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() diff --git a/app/modules/loyalty/services/program_service.py b/app/modules/loyalty/services/program_service.py index bd505772..145efc2f 100644 --- a/app/modules/loyalty/services/program_service.py +++ b/app/modules/loyalty/services/program_service.py @@ -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, } diff --git a/app/modules/loyalty/static/store/js/loyalty-cards.js b/app/modules/loyalty/static/store/js/loyalty-cards.js index 46c8cf31..3ed83a43 100644 --- a/app/modules/loyalty/static/store/js/loyalty-cards.js +++ b/app/modules/loyalty/static/store/js/loyalty-cards.js @@ -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; diff --git a/app/modules/loyalty/static/store/js/loyalty-settings.js b/app/modules/loyalty/static/store/js/loyalty-settings.js new file mode 100644 index 00000000..3ebf4a85 --- /dev/null +++ b/app/modules/loyalty/static/store/js/loyalty-settings.js @@ -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'); diff --git a/app/modules/loyalty/static/store/js/loyalty-stats.js b/app/modules/loyalty/static/store/js/loyalty-stats.js index 971b6206..b4848c02 100644 --- a/app/modules/loyalty/static/store/js/loyalty-stats.js +++ b/app/modules/loyalty/static/store/js/loyalty-stats.js @@ -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.loadStats(); + 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; diff --git a/app/modules/loyalty/static/store/js/loyalty-terminal.js b/app/modules/loyalty/static/store/js/loyalty-terminal.js index 652272a6..59f264f4 100644 --- a/app/modules/loyalty/static/store/js/loyalty-terminal.js +++ b/app/modules/loyalty/static/store/js/loyalty-terminal.js @@ -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 }); diff --git a/app/modules/loyalty/templates/loyalty/store/cards.html b/app/modules/loyalty/templates/loyalty/store/cards.html index 971cabf1..9b82f3cc 100644 --- a/app/modules/loyalty/templates/loyalty/store/cards.html +++ b/app/modules/loyalty/templates/loyalty/store/cards.html @@ -26,8 +26,28 @@ {{ error_state('Error loading members') }} + +
+
+ +
+

Loyalty Program Not Set Up

+

Your merchant doesn't have a loyalty program configured yet.

+ {% if user.role == 'merchant_owner' %} + + + Set Up Loyalty Program + + {% else %} +

Contact your administrator to complete the setup.

+ {% endif %} +
+
+
+ -
+
@@ -67,7 +87,7 @@
-
+
@@ -91,7 +111,7 @@
-
+
{% call table_wrapper() %} {{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', 'Actions']) }} diff --git a/app/modules/loyalty/templates/loyalty/store/settings.html b/app/modules/loyalty/templates/loyalty/store/settings.html new file mode 100644 index 00000000..c20b34c5 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/store/settings.html @@ -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 %} + +{% call page_header_flex(title='Loyalty Settings', subtitle='Configure your loyalty program') %} + +{% endcall %} + +{{ loading_state('Loading settings...') }} + +{{ error_state('Error loading settings') }} + + +
+
+ +
+

Access Restricted

+

Only the merchant owner can manage loyalty program settings.

+
+
+
+ + +
+ + +
+
+

+ + Program Type +

+
+
+
+ + + +
+
+
+ + +
+
+

Stamps Configuration

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

Points Configuration

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+
+

Anti-Fraud Settings

+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+

Branding

+
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + Cancel + + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/store/stats.html b/app/modules/loyalty/templates/loyalty/store/stats.html index 476bd249..84b44a1c 100644 --- a/app/modules/loyalty/templates/loyalty/store/stats.html +++ b/app/modules/loyalty/templates/loyalty/store/stats.html @@ -17,7 +17,27 @@ {{ loading_state('Loading statistics...') }} {{ error_state('Error loading statistics') }} -
+ +
+
+ +
+

Loyalty Program Not Set Up

+

Your merchant doesn't have a loyalty program configured yet.

+ {% if user.role == 'merchant_owner' %} + + + Set Up Loyalty Program + + {% else %} +

Contact your administrator to complete the setup.

+ {% endif %} +
+
+
+ +
diff --git a/app/modules/loyalty/templates/loyalty/store/terminal.html b/app/modules/loyalty/templates/loyalty/store/terminal.html index 39c97c25..e2b2da31 100644 --- a/app/modules/loyalty/templates/loyalty/store/terminal.html +++ b/app/modules/loyalty/templates/loyalty/store/terminal.html @@ -36,11 +36,15 @@

Loyalty Program Not Set Up

Your merchant doesn't have a loyalty program configured yet.

+ {% if user.role == 'merchant_owner' %} Set Up Loyalty Program + {% else %} +

Contact your administrator to complete the setup.

+ {% endif %}
@@ -125,70 +129,127 @@
- +
-

Points Balance

-

+ + + +
- +
- -
-

- - Earn Points -

-
- -
- EUR - -
-
-

- Points to award: -

- -
- -
-

- - Redeem Reward -

-
- - -
- + + + + + + +
diff --git a/app/modules/loyalty/tests/integration/test_store_api.py b/app/modules/loyalty/tests/integration/test_store_api.py index b529ea8b..cb7ea1f3 100644 --- a/app/modules/loyalty/tests/integration/test_store_api.py +++ b/app/modules/loyalty/tests/integration/test_store_api.py @@ -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 diff --git a/app/modules/loyalty/tests/unit/__init__.py b/app/modules/loyalty/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/loyalty/tests/unit/test_pin_service.py b/app/modules/loyalty/tests/unit/test_pin_service.py index 045a9ea3..3d90158a 100644 --- a/app/modules/loyalty/tests/unit/test_pin_service.py +++ b/app/modules/loyalty/tests/unit/test_pin_service.py @@ -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" diff --git a/app/modules/loyalty/tests/unit/test_points_service.py b/app/modules/loyalty/tests/unit/test_points_service.py index ac391293..e0dfaa1b 100644 --- a/app/modules/loyalty/tests/unit/test_points_service.py +++ b/app/modules/loyalty/tests/unit/test_points_service.py @@ -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 diff --git a/app/modules/loyalty/tests/unit/test_program_service.py b/app/modules/loyalty/tests/unit/test_program_service.py index 6b26db34..9de2820d 100644 --- a/app/modules/loyalty/tests/unit/test_program_service.py +++ b/app/modules/loyalty/tests/unit/test_program_service.py @@ -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 diff --git a/app/modules/loyalty/tests/unit/test_stamp_service.py b/app/modules/loyalty/tests/unit/test_stamp_service.py index 1940085d..42d9607c 100644 --- a/app/modules/loyalty/tests/unit/test_stamp_service.py +++ b/app/modules/loyalty/tests/unit/test_stamp_service.py @@ -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 diff --git a/app/modules/tenancy/models/store_platform.py b/app/modules/tenancy/models/store_platform.py index 6d53e99c..c2c3378e 100644 --- a/app/modules/tenancy/models/store_platform.py +++ b/app/modules/tenancy/models/store_platform.py @@ -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", - ), ) # ======================================================================== diff --git a/app/modules/tenancy/routes/api/store_auth.py b/app/modules/tenancy/routes/api/store_auth.py index bdd95115..c08dbf0f 100644 --- a/app/modules/tenancy/routes/api/store_auth.py +++ b/app/modules/tenancy/routes/api/store_auth.py @@ -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,27 +117,48 @@ 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 - from app.modules.tenancy.services.platform_service import platform_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) + 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: - plat = platform_service.get_platform_by_id(db, primary_pid) - if plat: - platform_id = plat.id - platform_code = plat.code + platform = platform_service.get_platform_by_id(db, primary_pid) - 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 + # 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}" + ) + + platform_id = platform.id if platform else None + platform_code = platform.code if platform else None # Create store-scoped access token with store information token_data = auth_service.auth_manager.create_access_token( @@ -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, ) diff --git a/app/modules/tenancy/routes/pages/store.py b/app/modules/tenancy/routes/pages/store.py index 8e778d48..2d3416b4 100644 --- a/app/modules/tenancy/routes/pages/store.py +++ b/app/modules/tenancy/routes/pages/store.py @@ -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), }, ) diff --git a/app/modules/tenancy/schemas/auth.py b/app/modules/tenancy/schemas/auth.py index 97149530..b95bc71a 100644 --- a/app/modules/tenancy/schemas/auth.py +++ b/app/modules/tenancy/schemas/auth.py @@ -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} diff --git a/app/modules/tenancy/services/merchant_domain_service.py b/app/modules/tenancy/services/merchant_domain_service.py index 4f86560a..3f0dd211 100644 --- a/app/modules/tenancy/services/merchant_domain_service.py +++ b/app/modules/tenancy/services/merchant_domain_service.py @@ -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 diff --git a/app/modules/tenancy/services/platform_service.py b/app/modules/tenancy/services/platform_service.py index ac421afa..ee6eea2c 100644 --- a/app/modules/tenancy/services/platform_service.py +++ b/app/modules/tenancy/services/platform_service.py @@ -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) diff --git a/app/modules/tenancy/services/store_domain_service.py b/app/modules/tenancy/services/store_domain_service.py index 330b850f..ce1c394a 100644 --- a/app/modules/tenancy/services/store_domain_service.py +++ b/app/modules/tenancy/services/store_domain_service.py @@ -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( diff --git a/app/modules/tenancy/static/store/js/login.js b/app/modules/tenancy/static/store/js/login.js index 7850beda..663e4f1c 100644 --- a/app/modules/tenancy/static/store/js/login.js +++ b/app/modules/tenancy/static/store/js/login.js @@ -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); diff --git a/app/modules/tenancy/templates/tenancy/store/login.html b/app/modules/tenancy/templates/tenancy/store/login.html index 8c4d37f7..00754d7a 100644 --- a/app/modules/tenancy/templates/tenancy/store/login.html +++ b/app/modules/tenancy/templates/tenancy/store/login.html @@ -12,6 +12,9 @@ + + +
@@ -234,7 +237,13 @@ })(); - + + + + + diff --git a/app/templates/shared/macros/feature_gate.html b/app/templates/shared/macros/feature_gate.html index 7aa149eb..9e9af8fe 100644 --- a/app/templates/shared/macros/feature_gate.html +++ b/app/templates/shared/macros/feature_gate.html @@ -374,44 +374,56 @@
- {# Step checklist #} -
-
{% endmacro %} diff --git a/app/templates/store/base.html b/app/templates/store/base.html index b671c1df..08058b35 100644 --- a/app/templates/store/base.html +++ b/app/templates/store/base.html @@ -26,6 +26,9 @@ [x-cloak] { display: none !important; } + + + {% block extra_head %}{% endblock %} diff --git a/docs/architecture/middleware.md b/docs/architecture/middleware.md index 67a2e6f1..a93af380 100644 --- a/docs/architecture/middleware.md +++ b/docs/architecture/middleware.md @@ -177,6 +177,52 @@ Injects: request.state.store = **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 diff --git a/docs/proposals/store-login-platform-detection.md b/docs/proposals/store-login-platform-detection.md index 0d2ef876..9dea1845 100644 --- a/docs/proposals/store-login-platform-detection.md +++ b/docs/proposals/store-login-platform-detection.md @@ -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 diff --git a/docs/proposals/store-menu-multi-platform-visibility.md b/docs/proposals/store-menu-multi-platform-visibility.md new file mode 100644 index 00000000..03c6416c --- /dev/null +++ b/docs/proposals/store-menu-multi-platform-visibility.md @@ -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) | diff --git a/scripts/seed/seed_demo.py b/scripts/seed/seed_demo.py index a1cefd3f..d5e27990 100644 --- a/scripts/seed/seed_demo.py +++ b/scripts/seed/seed_demo.py @@ -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,10 +1279,12 @@ 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: - store_primary_platform[store_id] = platform_id + if store_id not in store_primary_platform: + store_primary_platform[store_id] = platform_id # Fallback: OMS platform ID oms_platform = db.execute( @@ -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({ diff --git a/scripts/show_urls.py b/scripts/show_urls.py index 70132111..7a745a95 100644 --- a/scripts/show_urls.py +++ b/scripts/show_urls.py @@ -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() diff --git a/static/shared/js/dev-toolbar.js b/static/shared/js/dev-toolbar.js new file mode 100644 index 00000000..f8bc791b --- /dev/null +++ b/static/shared/js/dev-toolbar.js @@ -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, '"'); + } + + 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 '' + level.toUpperCase() + ''; + } + + // ── HTML Helpers ── + function sectionHeader(title) { + return '
' + escapeHtml(title) + '
'; + } + + function row(label, value, highlightFn) { + var color = highlightFn ? highlightFn(value) : C.text; + return '
' + + '' + escapeHtml(label) + '' + + '' + escapeHtml(String(value)) + '
'; + } + + // ── 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 = '
'; + 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 + ''; + 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('' + errCount + 'E'); + if (warnCount > 0) parts.push('' + warnCount + 'W'); + 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 += '
' + + 'STORE_PLATFORM_CODE || localStorage.store_platform || null
'; + + 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 += '
MISMATCH: JWT platform_code=' + + escapeHtml(jwtPlatform) + ' but localStorage.store_platform=' + escapeHtml(lsPlatform) + '
'; + } else if (jwtPlatform !== '(none)' && windowPlatform !== undefined && windowPlatform !== null && + String(windowPlatform) !== '(undefined)' && jwtPlatform !== windowPlatform) { + html += '
WARNING: JWT platform_code=' + + escapeHtml(jwtPlatform) + ' but window.STORE_PLATFORM_CODE=' + escapeHtml(String(windowPlatform)) + '
'; + } else { + html += '
All sources consistent
'; + } + + contentEl.innerHTML = html; + + // Async /auth/me + fetchAuthMe().then(function (me) { + var meHtml = sectionHeader('/auth/me (server)'); + if (me.error) { + meHtml += '
' + escapeHtml(me.error) + '
'; + } 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 += '
MISMATCH: /me platform_code=' + + escapeHtml(me.platform_code) + ' but JWT=' + escapeHtml(jwtPlatform) + '
'; + } + } + if (contentEl && activeTab === 'platform') { + contentEl.innerHTML += meHtml; + } + }); + } + + function renderApiCallsTab() { + var html = ''; + + // Toolbar row + html += '
'; + html += '' + apiCalls.length + ' requests captured'; + html += ''; + html += '
'; + + if (apiCalls.length === 0) { + html += '
No API calls captured yet.
'; + } else { + // Table header + html += '
'; + html += 'Method'; + html += 'URL'; + html += 'Status'; + html += 'Time'; + html += 'When'; + html += '
'; + + // 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 += '
'; + html += '
'; + html += '' + call.method + ''; + html += '' + escapeHtml(call.url) + ''; + html += '' + + (call.status !== null ? call.status : '\u2022\u2022\u2022') + ''; + html += '' + + (call.duration !== null ? formatDuration(call.duration) : '\u2022\u2022\u2022') + ''; + html += '' + formatTime(call.timestamp) + ''; + html += '
'; + + // Expanded detail + if (isExpanded_) { + html += '
'; + if (call.requestHeaders) { + html += '
Request Headers
'; + html += '
' +
+                            escapeHtml(JSON.stringify(call.requestHeaders, null, 2)) + '
'; + } + if (call.requestBody) { + html += '
Request Body
'; + html += '
' + escapeHtml(formatJsonSafe(call.requestBody)) + '
'; + } + if (call.responseBody) { + html += '
Response Body
'; + html += '
' +
+                            escapeHtml(formatJsonSafe(call.responseBody)) + '
'; + } + if (call.error) { + html += '
Error: ' + escapeHtml(call.error) + '
'; + } + html += '
'; + } + html += '
'; + } + } + + 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 += '
STORE_CONFIG not defined
'; + } + + html += sectionHeader('User Permissions'); + if (info.userPermissions && Array.isArray(info.userPermissions)) { + if (info.userPermissions.length === 0) { + html += '
(empty array)
'; + } else { + info.userPermissions.forEach(function (perm) { + html += '
\u2022 ' + escapeHtml(String(perm)) + '
'; + }); + } + } else { + html += '
USER_PERMISSIONS not defined
'; + } + + 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 += '
LogConfig not defined
'; + } + + contentEl.innerHTML = html; + } + + function renderConsoleTab() { + var html = ''; + + // Filter bar + html += '
'; + 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 += ''; + }); + html += '
'; + html += ''; + html += '
'; + + // Filtered logs + var filtered = consoleLogs; + if (consoleFilter !== 'all') { + filtered = consoleLogs.filter(function (l) { return l.level === consoleFilter; }); + } + + if (filtered.length === 0) { + html += '
No console output captured.
'; + } else { + for (var i = filtered.length - 1; i >= 0; i--) { + var entry = filtered[i]; + html += '
'; + html += '' + formatTime(entry.timestamp) + ''; + html += '' + levelBadge(entry.level) + ''; + html += ''; + html += escapeHtml(entry.args.join(' ')); + html += '
'; + } + } + + 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(); + } +})();