feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- Add admin SQL query tool with saved queries, schema explorer presets, and collapsible category sections (dev_tools module) - Add platform debug tool for admin diagnostics - Add loyalty settings page with owner-only access control - Fix loyalty settings owner check (use currentUser instead of window.__userData) - Replace HTTPException with AuthorizationException in loyalty routes - Expand loyalty module with PIN service, Apple Wallet, program management - Improve store login with platform detection and multi-platform support - Update billing feature gates and subscription services - Add store platform sync improvements and remove is_primary column - Add unit tests for loyalty (PIN, points, stamps, program services) - Update i18n translations across dev_tools locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,13 +87,6 @@ class StorePlatform(Base, TimestampMixin):
|
||||
comment="Whether the store is active on this platform",
|
||||
)
|
||||
|
||||
is_primary = Column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
comment="Whether this is the store's primary platform",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Platform-Specific Configuration
|
||||
# ========================================================================
|
||||
@@ -165,11 +158,6 @@ class StorePlatform(Base, TimestampMixin):
|
||||
"platform_id",
|
||||
"is_active",
|
||||
),
|
||||
Index(
|
||||
"idx_store_platform_primary",
|
||||
"store_id",
|
||||
"is_primary",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -48,6 +48,7 @@ class StoreLoginResponse(BaseModel):
|
||||
user: dict
|
||||
store: dict
|
||||
store_role: str
|
||||
platform_code: str | None = None
|
||||
|
||||
|
||||
@store_auth_router.post("/login", response_model=StoreLoginResponse)
|
||||
@@ -116,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -64,20 +64,36 @@ async def store_login_page(
|
||||
"""
|
||||
Render store login page.
|
||||
|
||||
If user is already authenticated as store, redirect to dashboard.
|
||||
Otherwise, show login form.
|
||||
If user is already authenticated on the SAME platform, redirect to dashboard.
|
||||
If authenticated on a DIFFERENT platform, show login form so the user can
|
||||
re-login with the correct platform context.
|
||||
"""
|
||||
if current_user:
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/dashboard", status_code=302
|
||||
)
|
||||
|
||||
language = getattr(request.state, "language", "fr")
|
||||
platform = getattr(request.state, "platform", None)
|
||||
platform_code = platform.code if platform else None
|
||||
|
||||
if current_user:
|
||||
# If the URL's platform matches the JWT's platform (or no platform in URL),
|
||||
# redirect to dashboard. Otherwise, show login form for platform switch.
|
||||
jwt_platform = current_user.token_platform_code
|
||||
same_platform = (
|
||||
platform_code is None
|
||||
or jwt_platform is None
|
||||
or platform_code == jwt_platform
|
||||
)
|
||||
if same_platform:
|
||||
if platform_code:
|
||||
redirect_url = f"/platforms/{platform_code}/store/{store_code}/dashboard"
|
||||
else:
|
||||
redirect_url = f"/store/{store_code}/dashboard"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/store/login.html",
|
||||
{
|
||||
"request": request,
|
||||
"store_code": store_code,
|
||||
"platform_code": platform_code,
|
||||
**get_jinja2_globals(language),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,9 @@ class UserLogin(BaseModel):
|
||||
store_code: str | None = Field(
|
||||
None, description="Optional store code for context"
|
||||
)
|
||||
platform_code: str | None = Field(
|
||||
None, description="Platform code from login context"
|
||||
)
|
||||
|
||||
@field_validator("email_or_username")
|
||||
@classmethod
|
||||
@@ -200,6 +203,7 @@ class StoreUserResponse(BaseModel):
|
||||
email: str
|
||||
role: str
|
||||
is_active: bool
|
||||
platform_code: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -115,8 +115,9 @@ class MerchantDomainService:
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id.in_(store_ids),
|
||||
StorePlatform.is_primary.is_(True),
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(StorePlatform.joined_at)
|
||||
.first()
|
||||
)
|
||||
platform_id = primary_sp.platform_id if primary_sp else None
|
||||
|
||||
@@ -324,9 +324,12 @@ class PlatformService:
|
||||
# ========================================================================
|
||||
|
||||
@staticmethod
|
||||
def get_primary_platform_id_for_store(db: Session, store_id: int) -> int | None:
|
||||
def get_first_active_platform_id_for_store(db: Session, store_id: int) -> int | None:
|
||||
"""
|
||||
Get the primary platform ID for a store.
|
||||
Get the first active platform ID for a store (ordered by joined_at).
|
||||
|
||||
Used as a fallback when platform_id is not available from JWT context
|
||||
(e.g. background tasks, old tokens).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -341,7 +344,7 @@ class PlatformService:
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(StorePlatform.is_primary.desc())
|
||||
.order_by(StorePlatform.joined_at)
|
||||
.first()
|
||||
)
|
||||
return result[0] if result else None
|
||||
@@ -364,7 +367,7 @@ class PlatformService:
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(StorePlatform.is_primary.desc())
|
||||
.order_by(StorePlatform.joined_at)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in results]
|
||||
@@ -393,29 +396,6 @@ class PlatformService:
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_primary_store_platform_entry(
|
||||
db: Session, store_id: int
|
||||
) -> StorePlatform | None:
|
||||
"""
|
||||
Get the primary StorePlatform entry for a store.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
|
||||
Returns:
|
||||
StorePlatform object or None
|
||||
"""
|
||||
return (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_primary.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_store_ids_for_platform(
|
||||
db: Session, platform_id: int, active_only: bool = True
|
||||
@@ -450,7 +430,7 @@ class PlatformService:
|
||||
Upsert a StorePlatform entry.
|
||||
|
||||
If the entry exists, update is_active (and tier_id if provided).
|
||||
If missing and is_active=True, create it (set is_primary if store has none).
|
||||
If missing and is_active=True, create it.
|
||||
If missing and is_active=False, no-op.
|
||||
|
||||
Args:
|
||||
@@ -479,20 +459,10 @@ class PlatformService:
|
||||
return existing
|
||||
|
||||
if is_active:
|
||||
has_primary = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_primary.is_(True),
|
||||
)
|
||||
.first()
|
||||
) is not None
|
||||
|
||||
sp = StorePlatform(
|
||||
store_id=store_id,
|
||||
platform_id=platform_id,
|
||||
is_active=True,
|
||||
is_primary=not has_primary,
|
||||
tier_id=tier_id,
|
||||
)
|
||||
db.add(sp)
|
||||
|
||||
@@ -105,16 +105,13 @@ class StoreDomainService:
|
||||
if domain_data.is_primary:
|
||||
self._unset_primary_domains(db, store_id)
|
||||
|
||||
# Resolve platform_id: use provided value, or auto-resolve from primary StorePlatform
|
||||
# Resolve platform_id: use provided value, or auto-resolve from first active StorePlatform
|
||||
platform_id = domain_data.platform_id
|
||||
if not platform_id:
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
primary_sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(StorePlatform.store_id == store_id, StorePlatform.is_primary.is_(True))
|
||||
.first()
|
||||
from app.modules.tenancy.services.platform_service import (
|
||||
platform_service,
|
||||
)
|
||||
platform_id = primary_sp.platform_id if primary_sp else None
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
|
||||
# Create domain record
|
||||
new_domain = StoreDomain(
|
||||
|
||||
@@ -126,7 +126,8 @@ function storeLogin() {
|
||||
const response = await apiClient.post('/store/auth/login', {
|
||||
email_or_username: this.credentials.username,
|
||||
password: this.credentials.password,
|
||||
store_code: this.storeCode
|
||||
store_code: this.storeCode,
|
||||
platform_code: window.STORE_PLATFORM_CODE || localStorage.getItem('store_platform') || null
|
||||
});
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
@@ -143,17 +144,30 @@ function storeLogin() {
|
||||
localStorage.setItem('store_token', response.access_token);
|
||||
localStorage.setItem('currentUser', JSON.stringify(response.user));
|
||||
localStorage.setItem('storeCode', this.storeCode);
|
||||
if (response.platform_code) {
|
||||
localStorage.setItem('store_platform', response.platform_code);
|
||||
}
|
||||
storeLoginLog.debug('Token stored as store_token in localStorage');
|
||||
|
||||
this.success = 'Login successful! Redirecting...';
|
||||
|
||||
// Build platform-aware base path
|
||||
const platformCode = window.STORE_PLATFORM_CODE;
|
||||
const basePath = platformCode
|
||||
? `/platforms/${platformCode}/store/${this.storeCode}`
|
||||
: `/store/${this.storeCode}`;
|
||||
|
||||
// Check for last visited page (saved before logout)
|
||||
const lastPage = localStorage.getItem('store_last_visited_page');
|
||||
const validLastPage = lastPage &&
|
||||
lastPage.startsWith(`/store/${this.storeCode}/`) &&
|
||||
!lastPage.includes('/login') &&
|
||||
!lastPage.includes('/onboarding');
|
||||
const redirectTo = validLastPage ? lastPage : `/store/${this.storeCode}/dashboard`;
|
||||
let redirectTo = `${basePath}/dashboard`;
|
||||
|
||||
if (lastPage && !lastPage.includes('/login') && !lastPage.includes('/onboarding')) {
|
||||
// Extract the store-relative path (strip any existing prefix)
|
||||
const storePathMatch = lastPage.match(/\/store\/[^/]+(\/.*)/);
|
||||
if (storePathMatch) {
|
||||
redirectTo = `${basePath}${storePathMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
storeLoginLog.info('Last visited page:', lastPage);
|
||||
storeLoginLog.info('Redirecting to:', redirectTo);
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
|
||||
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
@@ -234,7 +237,13 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 6. Store Login Logic -->
|
||||
<!-- 6. Platform context for login -->
|
||||
<script>
|
||||
window.STORE_PLATFORM_CODE = {{ platform_code|tojson }};
|
||||
</script>
|
||||
|
||||
<!-- 7. Store Login Logic -->
|
||||
<script defer src="{{ url_for('tenancy_static', path='store/js/login.js') }}"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user