feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add admin SQL query tool with saved queries, schema explorer presets,
  and collapsible category sections (dev_tools module)
- Add platform debug tool for admin diagnostics
- Add loyalty settings page with owner-only access control
- Fix loyalty settings owner check (use currentUser instead of window.__userData)
- Replace HTTPException with AuthorizationException in loyalty routes
- Expand loyalty module with PIN service, Apple Wallet, program management
- Improve store login with platform detection and multi-platform support
- Update billing feature gates and subscription services
- Add store platform sync improvements and remove is_primary column
- Add unit tests for loyalty (PIN, points, stamps, program services)
- Update i18n translations across dev_tools locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 20:08:07 +01:00
parent a77a8a3a98
commit 319900623a
77 changed files with 5341 additions and 401 deletions

View File

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

View File

@@ -48,6 +48,7 @@ class StoreLoginResponse(BaseModel):
user: dict
store: dict
store_role: str
platform_code: str | None = None
@store_auth_router.post("/login", response_model=StoreLoginResponse)
@@ -116,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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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