Compare commits

...

29 Commits

Author SHA1 Message Date
71b5eb1758 fix(ui): add window.FRONTEND_TYPE to standalone login pages
Some checks failed
CI / ruff (push) Successful in 46s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Login pages don't extend base templates, so they need the
FRONTEND_TYPE injection directly. Fixes "unknown" frontend
in dev toolbar and log prefixes on login pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:10:39 +01:00
b4f01210d9 fix(ui): inject window.FRONTEND_TYPE from server + rename SHOP→STOREFRONT
Server now injects window.FRONTEND_TYPE in all base templates via
get_context_for_frontend(). Both log-config.js and dev-toolbar.js read
this instead of guessing from URL paths, fixing:
- UNKNOWN prefix on merchant pages
- Incorrect detection on custom domains/subdomains in prod

Also adds frontend_type to login page contexts (admin, merchant, store).

Renames all [SHOP] logger prefixes to [STOREFRONT] across 7 files
(storefront-layout.js + 6 storefront templates).

Adds 'merchant' and 'storefront' to log-config.js frontend detection,
log levels, and logger selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:08:59 +01:00
9bceeaac9c feat(arch): implement soft delete for business-critical models
Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.

Models: User, Merchant, Store, StoreUser, Customer, Order, Product,
LoyaltyProgram, LoyaltyCard.

Infrastructure:
- SoftDeleteMixin in models/database/base.py
- Auto query filter registered on SessionLocal and test sessions
- soft_delete(), restore(), soft_delete_cascade() in app/core/soft_delete.py
- Alembic migration adding columns to 9 tables
- Partial unique indexes on users.email/username, stores.store_code/subdomain

Service changes:
- admin_service: delete_user, delete_store → soft_delete/soft_delete_cascade
- merchant_service: delete_merchant → soft_delete_cascade (stores→children)
- store_team_service: remove_team_member → soft_delete (fixes is_active bug)
- product_service: delete_product → soft_delete
- program_service: delete_program → soft_delete_cascade

Admin API:
- include_deleted/only_deleted query params on admin list endpoints
- PUT restore endpoints for users, merchants, stores

Tests: 9 unit tests for soft-delete infrastructure.
Docs: docs/backend/soft-delete.md + follow-up proposals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:08:07 +01:00
332960de30 fix(tenancy): fix team CRUD bugs + add member integration tests
Store team page:
- Fix undefined user_id (API returns `id`, JS used `user_id`)
- Fix wrong URL path in updateMember (remove redundant storeCode)
- Fix update_member_role route passing wrong kwarg (new_role_id → new_role_name)
- Add update_member() service method for role_id + is_active updates
- Add :selected binding for role pre-selection in edit modal

Merchant team page:
- Add missing db.commit() on invite, update, and remove endpoints
- Fix forward-reference string type annotation on MerchantTeamInvite
- Add :selected binding for role pre-selection in edit modal

Shared fixes:
- Replace removed subscription_service.check_team_limit with usage_service
- Replace removed subscription_service.get_current_tier in email service
- Fix email config bool settings crashing on .lower() (value_type=boolean)

Tests: 15 new integration tests for store team member API endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:06:21 +01:00
0455e63a2e feat(tenancy): add merchant team CRUD with multi-store hub view
The merchant team page was read-only. Now merchant owners can invite,
edit roles, and remove team members across all their stores from a
single hub view.

Architecture: No new models — delegates to existing store_team_service.
Members are deduplicated across stores with per-store role badges.

New:
- 5 API endpoints: GET team (member-centric), GET store roles, POST
  invite (multi-store), PUT update role, DELETE remove member
- merchant-team.js Alpine component with invite/edit/remove modals
- Full CRUD template with stats cards, store filter, member table
- 7 Pydantic schemas for merchant team request/response
- 2 service methods: validate_store_ownership, get_merchant_team_members
- 25 new i18n keys across 4 tenancy locales + 1 core common key

Tests: 434 tenancy tests passing, arch-check green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:57:45 +01:00
aaed1b2d01 fix(tenancy): use correct Merchant.name field in team service
merchant_store_service referenced merchant.business_name and
merchant.brand_name which don't exist on the Merchant model.
The field is simply merchant.name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:58:46 +01:00
9dee534b2f fix(tenancy): correct API path for merchant team page
JS was calling /merchants/tenancy/account/team but the endpoint is
mounted at /merchants/account/team (no tenancy prefix in the path).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:56:49 +01:00
beef3ce76b fix(arch): extend TPL-009 block name check to merchant templates
The block name validation (scripts → extra_scripts, etc.) only checked
admin and store templates, missing merchant. Added is_merchant flag.
This would have caught the {% block scripts %} bug in merchant/team.html.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:55:06 +01:00
884a694718 fix(tenancy): use correct block name for merchant team page scripts
Template used {% block scripts %} but merchant base.html defines
{% block extra_scripts %}. The merchantTeam() function never rendered,
causing "merchantTeam is not defined" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:50:49 +01:00
4cafbe9610 fix(tenancy): use Python .lower() instead of JS .toLowerCase() in template
Merchant team page called .toLowerCase() on a Jinja2 string (Python),
causing UndefinedError. Fixed to .lower().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:48:33 +01:00
19923ed26b fix(loyalty): remove avatar circle from transactions list
The first-letter avatar adds visual noise on a dense transactions table
without meaningful value. Simplified to plain text customer name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:45:45 +01:00
46f8d227b8 fix(loyalty): remove card_number display from transactions list
TransactionResponse doesn't include card_number, so the template was
showing '-' under every customer name. Removed the nonexistent field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:44:45 +01:00
95e4956216 fix(loyalty): make edit PIN modal read-only except for PIN code
When editing a PIN, only the PIN code should be changeable. Staff name,
staff ID, and store are now displayed as read-only fields. This prevents
accidentally reassigning a PIN to a different staff member.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:36:11 +01:00
77e520bbce fix(loyalty): use correct no-results text in PIN staff autocomplete
PIN create/edit modals were showing "Customer not found" (terminal
message) when no staff members matched. Now shows "No staff members
found" with a proper locale key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:33:09 +01:00
518bace534 refactor(loyalty): use search_autocomplete macro for staff PIN lookup
Replace custom inline autocomplete HTML in both create and edit PIN
modals with the shared search_autocomplete macro from inputs.html.
Refactored JS to use staffSearchResults array populated by searchStaff()
(client-side filter) matching the macro's conventions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:30:10 +01:00
fcde2d68fc fix(loyalty): use SQL func.replace() for card number search
list_cards() was calling Python .replace() on a SQLAlchemy column
object instead of SQL func.replace(), causing AttributeError when
searching cards by card number.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:25:28 +01:00
5a33f68743 refactor(loyalty): use search_autocomplete macro for terminal lookup
Replace custom inline autocomplete HTML with the shared
search_autocomplete macro from inputs.html. Same behavior (debounced
search, dropdown with name + email, loading/no-results states) but
using the established reusable component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:24:00 +01:00
040cbd1962 feat(loyalty): add customer autocomplete to terminal search
Terminal search now shows live autocomplete suggestions as the user
types (debounced 300ms, min 2 chars). Dropdown shows matching customers
with avatar, name, email, card number, and points balance. Uses the
existing GET /store/loyalty/cards?search= endpoint (limit=5).

Selecting a result loads the full card details via the lookup endpoint.
Enter key still works for exact lookup. No new dependencies — uses
native Alpine.js dropdown, no Tom Select needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:21:36 +01:00
b679c9687d fix(loyalty): only show staff dropdown after typing, not on focus
The autocomplete dropdown appeared immediately when the name field
gained focus (even when empty). Now only shows when there's text to
filter by.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:14:35 +01:00
314360a394 fix(loyalty): clear staff_id when autocomplete selection is removed
When a staff member was selected and then the name field was edited or
cleared, the staff_id (email) remained set. Now tracks the selected
member name and clears staff_id when the search text diverges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:13:44 +01:00
44a0c38016 fix(loyalty): remove broken pagination from pins list
The pins list template included the pagination macro but the JS has no
pagination state (PINs are few and don't need pagination). The empty
macro rendered a broken pagination bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:12:08 +01:00
da9e1ab293 fix(core): handle 204 No Content in apiClient JSON parsing
The shared apiClient unconditionally called response.json() on every
response, including 204 No Content (returned by DELETE endpoints).
This caused "Invalid JSON response from server" errors on all delete
operations across all modules and personas.

Now returns null for 204 responses without attempting JSON parse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:10:17 +01:00
5de297a804 fix(loyalty): fix edit/delete button handlers in pins list
Template called openEditPin() and confirmDeletePin() but JS methods
are openEditModal() and openDeleteModal(). Buttons were silently
failing on click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:07:21 +01:00
4429674100 feat(loyalty): add staff autocomplete to PIN management
When creating or editing a staff PIN in the store context, the name
field now shows an autocomplete dropdown with the store's team members
(loaded from GET /store/team/members). Selecting a member auto-fills
name and staff_id (email). The dropdown filters as you type.

Only active in store context (where staffApiPrefix is configured).
Merchant and admin PIN views are unaffected — merchant has no
staffApiPrefix, admin is read-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:58:10 +01:00
316ec42566 fix(loyalty): use card_id instead of id in terminal JS
The terminal's selectedCard comes from CardLookupResponse which uses
card_id field, but the JS was referencing selectedCard.id (undefined).
This caused all terminal transactions to fail with "LoyaltyCard with
identifier 'unknown' not found" instead of processing the transaction
or showing proper PIN validation errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:50:20 +01:00
894832c62b fix(loyalty): add all 27 remaining missing i18n keys
Comprehensive audit found 618 total translation references across all
templates and JS files. Added 27 missing keys to all 4 locale files:
- store.terminal: card_label, confirm, pin_authorize_text, free_item,
  reward_label, search_empty_state
- store.card_detail: card_label
- store.enroll: bonus_points, card_number_label, points
- store.settings: access_restricted_desc, delete_program_* (3 keys)
- common: setup_program, unknown
- errors: card_not_found
- shared.pins: save_changes, unlock
- toasts: pin_created/updated/deleted/unlocked + error variants (8 keys)

All 618 keys now resolve. 778 total keys per locale file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:29:21 +01:00
1d90bfe044 fix(loyalty): align menu item IDs with URL segments for sidebar highlight
The store and merchant init-alpine.js derive currentPage from the URL's
last segment (e.g., /loyalty/program -> 'program'). Loyalty menu items
used prefixed IDs like 'loyalty-program' which never matched, so sidebar
items never highlighted.

Fixed by renaming all store/merchant menu item IDs and JS currentPage
values to match URL segments: program, cards, analytics, transactions,
pins, settings — consistent with how every other module works.

Also reverted the init-alpine.js guard that broke storeCode extraction,
and added missing loyalty.common.contact_admin_setup translation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:32:50 +01:00
ce0caa5685 fix(core): don't overwrite currentPage set by child Alpine components
The store init-alpine.js init() was unconditionally setting currentPage
from the URL path segment, overwriting the value set by child components
like storeLoyaltyProgram (currentPage: 'loyalty-program'). This caused
sidebar menu items to not highlight on pages where the URL segment
doesn't match the menu item ID (e.g., /loyalty/program vs loyalty-program).

Now only sets currentPage from URL if the child hasn't already set it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:22:20 +01:00
33f823aba0 fix(loyalty): rename table_* locale keys to col_* matching template references
Store templates (cards, card-detail, terminal) reference col_member,
col_date etc. but locale files had table_member, table_date. Renamed
16 keys across all 4 locale files (en/fr/de/lb) to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:15:20 +01:00
94 changed files with 6659 additions and 3863 deletions

View File

@@ -0,0 +1,118 @@
"""Add soft delete columns (deleted_at, deleted_by_id) to business-critical tables.
Also converts unique constraints on users.email, users.username,
stores.store_code, stores.subdomain to partial unique indexes
that only apply to non-deleted rows.
Revision ID: softdelete_001
Revises: remove_is_primary_001, customers_002, dev_tools_002, orders_002, tenancy_004
Create Date: 2026-03-28
"""
from alembic import op
import sqlalchemy as sa
revision = "softdelete_001"
down_revision = (
"remove_is_primary_001",
"customers_002",
"dev_tools_002",
"orders_002",
"tenancy_004",
)
branch_labels = None
depends_on = None
# Tables receiving soft-delete columns
SOFT_DELETE_TABLES = [
"users",
"merchants",
"stores",
"customers",
"store_users",
"orders",
"products",
"loyalty_programs",
"loyalty_cards",
]
def upgrade() -> None:
# ======================================================================
# Step 1: Add deleted_at and deleted_by_id to all soft-delete tables
# ======================================================================
for table in SOFT_DELETE_TABLES:
op.add_column(table, sa.Column("deleted_at", sa.DateTime(), nullable=True))
op.add_column(
table,
sa.Column(
"deleted_by_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(f"ix_{table}_deleted_at", table, ["deleted_at"])
# ======================================================================
# Step 2: Replace simple unique constraints with partial unique indexes
# (only enforce uniqueness among non-deleted rows)
# ======================================================================
# users.email: drop old unique index, create partial
op.drop_index("ix_users_email", table_name="users")
op.execute(
'CREATE UNIQUE INDEX uq_users_email_active ON users (email) '
'WHERE deleted_at IS NULL'
)
# Keep a non-unique index for lookups on all rows (including deleted)
op.create_index("ix_users_email", "users", ["email"])
# users.username: drop old unique index, create partial
op.drop_index("ix_users_username", table_name="users")
op.execute(
'CREATE UNIQUE INDEX uq_users_username_active ON users (username) '
'WHERE deleted_at IS NULL'
)
op.create_index("ix_users_username", "users", ["username"])
# stores.store_code: drop old unique index, create partial
op.drop_index("ix_stores_store_code", table_name="stores")
op.execute(
'CREATE UNIQUE INDEX uq_stores_store_code_active ON stores (store_code) '
'WHERE deleted_at IS NULL'
)
op.create_index("ix_stores_store_code", "stores", ["store_code"])
# stores.subdomain: drop old unique index, create partial
op.drop_index("ix_stores_subdomain", table_name="stores")
op.execute(
'CREATE UNIQUE INDEX uq_stores_subdomain_active ON stores (subdomain) '
'WHERE deleted_at IS NULL'
)
op.create_index("ix_stores_subdomain", "stores", ["subdomain"])
def downgrade() -> None:
# Reverse partial unique indexes back to simple unique indexes
op.drop_index("ix_stores_subdomain", table_name="stores")
op.execute("DROP INDEX IF EXISTS uq_stores_subdomain_active")
op.create_index("ix_stores_subdomain", "stores", ["subdomain"], unique=True)
op.drop_index("ix_stores_store_code", table_name="stores")
op.execute("DROP INDEX IF EXISTS uq_stores_store_code_active")
op.create_index("ix_stores_store_code", "stores", ["store_code"], unique=True)
op.drop_index("ix_users_username", table_name="users")
op.execute("DROP INDEX IF EXISTS uq_users_username_active")
op.create_index("ix_users_username", "users", ["username"], unique=True)
op.drop_index("ix_users_email", table_name="users")
op.execute("DROP INDEX IF EXISTS uq_users_email_active")
op.create_index("ix_users_email", "users", ["email"], unique=True)
# Remove soft-delete columns from all tables
for table in reversed(SOFT_DELETE_TABLES):
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
op.drop_column(table, "deleted_by_id")
op.drop_column(table, "deleted_at")

View File

@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
import logging
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy import create_engine, event
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
from sqlalchemy.pool import QueuePool
from .config import settings, validate_database_url
@@ -38,6 +38,45 @@ Base = declarative_base()
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Soft-delete automatic query filter
# ---------------------------------------------------------------------------
# Any model that inherits SoftDeleteMixin will automatically have
# `WHERE deleted_at IS NULL` appended to SELECT queries.
# Bypass with: db.execute(stmt, execution_options={"include_deleted": True})
# or db.query(Model).execution_options(include_deleted=True).all()
# ---------------------------------------------------------------------------
def register_soft_delete_filter(session_factory):
"""Register the soft-delete query filter on a session factory.
Call this for any sessionmaker that should auto-exclude soft-deleted records.
Used for both the production SessionLocal and test session factories.
"""
@event.listens_for(session_factory, "do_orm_execute")
def _soft_delete_filter(orm_execute_state):
if (
orm_execute_state.is_select
and not orm_execute_state.execution_options.get("include_deleted", False)
):
from models.database.base import SoftDeleteMixin
orm_execute_state.statement = orm_execute_state.statement.options(
with_loader_criteria(
SoftDeleteMixin,
lambda cls: cls.deleted_at.is_(None),
include_aliases=True,
)
)
return _soft_delete_filter
# Register on the production session factory
register_soft_delete_filter(SessionLocal)
def get_db():
"""
Database session dependency for FastAPI routes.

143
app/core/soft_delete.py Normal file
View File

@@ -0,0 +1,143 @@
# app/core/soft_delete.py
"""
Soft-delete utility functions.
Provides helpers for soft-deleting, restoring, and cascade soft-deleting
records that use the SoftDeleteMixin.
Usage:
from app.core.soft_delete import soft_delete, restore, soft_delete_cascade
# Simple soft delete
soft_delete(db, user, deleted_by_id=admin.id)
# Cascade soft delete (merchant + all stores + their children)
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
("stores", [("products", []), ("customers", []), ("orders", []), ("store_users", [])]),
])
# Restore a soft-deleted record
from app.modules.tenancy.models import User
restore(db, User, entity_id=42, restored_by_id=admin.id)
"""
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def soft_delete(db: Session, entity, deleted_by_id: int | None = None) -> None:
"""
Mark an entity as soft-deleted.
Sets deleted_at to now and deleted_by_id to the actor.
Does NOT call db.commit() — caller is responsible.
Args:
db: Database session.
entity: SQLAlchemy model instance with SoftDeleteMixin.
deleted_by_id: ID of the user performing the deletion.
"""
entity.deleted_at = datetime.now(UTC)
entity.deleted_by_id = deleted_by_id
db.flush()
logger.info(
f"Soft-deleted {entity.__class__.__name__} id={entity.id} "
f"by user_id={deleted_by_id}"
)
def restore(
db: Session,
model_class,
entity_id: int,
restored_by_id: int | None = None,
):
"""
Restore a soft-deleted entity.
Queries with include_deleted=True to find the record, then clears
deleted_at and deleted_by_id.
Args:
db: Database session.
model_class: SQLAlchemy model class.
entity_id: ID of the entity to restore.
restored_by_id: ID of the user performing the restore (for logging).
Returns:
The restored entity.
Raises:
ValueError: If entity not found.
"""
entity = db.execute(
select(model_class).filter(model_class.id == entity_id),
execution_options={"include_deleted": True},
).scalar_one_or_none()
if entity is None:
raise ValueError(f"{model_class.__name__} with id={entity_id} not found")
if entity.deleted_at is None:
raise ValueError(f"{model_class.__name__} with id={entity_id} is not deleted")
entity.deleted_at = None
entity.deleted_by_id = None
db.flush()
logger.info(
f"Restored {model_class.__name__} id={entity_id} "
f"by user_id={restored_by_id}"
)
return entity
def soft_delete_cascade(
db: Session,
entity,
deleted_by_id: int | None = None,
cascade_rels: list[tuple[str, list]] | None = None,
) -> int:
"""
Soft-delete an entity and recursively soft-delete its children.
Args:
db: Database session.
entity: SQLAlchemy model instance with SoftDeleteMixin.
deleted_by_id: ID of the user performing the deletion.
cascade_rels: List of (relationship_name, child_cascade_rels) tuples.
Example: [("stores", [("products", []), ("customers", [])])]
Returns:
Total number of records soft-deleted (including the root entity).
"""
count = 0
# Soft-delete the entity itself
soft_delete(db, entity, deleted_by_id)
count += 1
# Recursively soft-delete children
if cascade_rels:
for rel_name, child_cascade in cascade_rels:
children = getattr(entity, rel_name, None)
if children is None:
continue
# Handle both collections and single items (uselist=False)
if not isinstance(children, list):
children = [children]
for child in children:
if hasattr(child, "deleted_at") and child.deleted_at is None:
count += soft_delete_cascade(
db, child, deleted_by_id, child_cascade
)
return count

View File

@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
// Initialize
async init() {
console.log('[SHOP] Cart page initializing...');
console.log('[STOREFRONT] Cart page initializing...');
// Call parent init to set up sessionId
if (baseData.init) {
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
this.loading = true;
try {
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
console.log(`[STOREFRONT] Loading cart for session ${this.sessionId}...`);
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
this.items = data.items || [];
this.cartCount = this.totalItems;
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
}
} catch (error) {
console.error('[SHOP] Failed to load cart:', error);
console.error('[STOREFRONT] Failed to load cart:', error);
this.showToast('Failed to load cart', 'error');
} finally {
this.loading = false;
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
this.updating = true;
try {
console.log('[SHOP] Updating quantity:', productId, newQuantity);
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
const response = await fetch(
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
{
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
throw new Error('Failed to update quantity');
}
} catch (error) {
console.error('[SHOP] Update quantity error:', error);
console.error('[STOREFRONT] Update quantity error:', error);
this.showToast('Failed to update quantity', 'error');
} finally {
this.updating = false;
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
this.updating = true;
try {
console.log('[SHOP] Removing item:', productId);
console.log('[STOREFRONT] Removing item:', productId);
const response = await fetch(
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
{
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
throw new Error('Failed to remove item');
}
} catch (error) {
console.error('[SHOP] Remove item error:', error);
console.error('[STOREFRONT] Remove item error:', error);
this.showToast('Failed to remove item', 'error');
} finally {
this.updating = false;

View File

@@ -26,10 +26,10 @@ from sqlalchemy.orm import relationship
from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
class Product(Base, TimestampMixin):
class Product(Base, TimestampMixin, SoftDeleteMixin):
"""Store-specific product.
Products can be created from marketplace imports or directly by stores.

View File

@@ -192,9 +192,11 @@ class ProductService:
True if deleted
"""
try:
from app.core.soft_delete import soft_delete
product = self.get_product(db, store_id, product_id)
db.delete(product)
soft_delete(db, product, deleted_by_id=None)
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
return True

View File

@@ -187,8 +187,8 @@ document.addEventListener('alpine:init', () => {
},
async init() {
console.log('[SHOP] Category page initializing...');
console.log('[SHOP] Category slug:', this.categorySlug);
console.log('[STOREFRONT] Category page initializing...');
console.log('[STOREFRONT] Category slug:', this.categorySlug);
// Convert slug to display name
this.categoryName = this.categorySlug
@@ -213,7 +213,7 @@ document.addEventListener('alpine:init', () => {
params.append('sort', this.sortBy);
}
console.log(`[SHOP] Loading category products from /api/v1/storefront/products?${params}`);
console.log(`[STOREFRONT] Loading category products from /api/v1/storefront/products?${params}`);
const response = await fetch(`/api/v1/storefront/products?${params}`);
@@ -223,12 +223,12 @@ document.addEventListener('alpine:init', () => {
const data = await response.json();
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
this.products = data.products;
this.total = data.total;
} catch (error) {
console.error('[SHOP] Failed to load category products:', error);
console.error('[STOREFRONT] Failed to load category products:', error);
this.showToast('Failed to load products', 'error');
} finally {
this.loading = false;
@@ -243,7 +243,7 @@ document.addEventListener('alpine:init', () => {
},
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
console.log('[STOREFRONT] Adding to cart:', product);
try {
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
@@ -262,16 +262,16 @@ document.addEventListener('alpine:init', () => {
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
console.log('[STOREFRONT] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
console.error('[STOREFRONT] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
console.error('[STOREFRONT] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}

View File

@@ -256,16 +256,16 @@ document.addEventListener('alpine:init', () => {
// Initialize
async init() {
console.log('[SHOP] Product detail page initializing...');
console.log('[STOREFRONT] Product detail page initializing...');
// Call parent init to set up sessionId
if (baseData.init) {
baseData.init.call(this);
}
console.log('[SHOP] Product ID:', this.productId);
console.log('[SHOP] Store ID:', this.storeId);
console.log('[SHOP] Session ID:', this.sessionId);
console.log('[STOREFRONT] Product ID:', this.productId);
console.log('[STOREFRONT] Store ID:', this.storeId);
console.log('[STOREFRONT] Session ID:', this.sessionId);
await this.loadProduct();
},
@@ -275,7 +275,7 @@ document.addEventListener('alpine:init', () => {
this.loading = true;
try {
console.log(`[SHOP] Loading product ${this.productId}...`);
console.log(`[STOREFRONT] Loading product ${this.productId}...`);
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
if (!response.ok) {
@@ -283,7 +283,7 @@ document.addEventListener('alpine:init', () => {
}
this.product = await response.json();
console.log('[SHOP] Product loaded:', this.product);
console.log('[STOREFRONT] Product loaded:', this.product);
// Set default image
if (this.product?.marketplace_product?.image_link) {
@@ -297,7 +297,7 @@ document.addEventListener('alpine:init', () => {
await this.loadRelatedProducts();
} catch (error) {
console.error('[SHOP] Failed to load product:', error);
console.error('[STOREFRONT] Failed to load product:', error);
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
@@ -320,10 +320,10 @@ document.addEventListener('alpine:init', () => {
.filter(p => p.id !== parseInt(this.productId))
.slice(0, 4);
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
console.log('[STOREFRONT] Loaded related products:', this.relatedProducts.length);
}
} catch (error) {
console.error('[SHOP] Failed to load related products:', error);
console.error('[STOREFRONT] Failed to load related products:', error);
}
},
@@ -356,7 +356,7 @@ document.addEventListener('alpine:init', () => {
// Add to cart
async addToCart() {
if (!this.canAddToCart) {
console.warn('[SHOP] Cannot add to cart:', {
console.warn('[STOREFRONT] Cannot add to cart:', {
canAddToCart: this.canAddToCart,
isActive: this.product?.is_active,
inventory: this.product?.available_inventory,
@@ -374,7 +374,7 @@ document.addEventListener('alpine:init', () => {
quantity: this.quantity
};
console.log('[SHOP] Adding to cart:', {
console.log('[STOREFRONT] Adding to cart:', {
url,
sessionId: this.sessionId,
productId: this.productId,
@@ -390,14 +390,14 @@ document.addEventListener('alpine:init', () => {
body: JSON.stringify(payload)
});
console.log('[SHOP] Add to cart response:', {
console.log('[STOREFRONT] Add to cart response:', {
status: response.status,
ok: response.ok
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
console.log('[STOREFRONT] Add to cart success:', result);
this.cartCount += this.quantity;
this.showToast(
@@ -409,11 +409,11 @@ document.addEventListener('alpine:init', () => {
this.quantity = this.product?.min_quantity || 1;
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error response:', error);
console.error('[STOREFRONT] Add to cart error response:', error);
throw new Error(error.detail || 'Failed to add to cart');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
console.error('[STOREFRONT] Add to cart exception:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
} finally {
this.addingToCart = false;

View File

@@ -160,7 +160,7 @@ document.addEventListener('alpine:init', () => {
},
async init() {
console.log('[SHOP] Products page initializing...');
console.log('[STOREFRONT] Products page initializing...');
await this.loadProducts();
},
@@ -178,7 +178,7 @@ document.addEventListener('alpine:init', () => {
params.append('search', this.filters.search);
}
console.log(`[SHOP] Loading products from /api/v1/storefront/products?${params}`);
console.log(`[STOREFRONT] Loading products from /api/v1/storefront/products?${params}`);
const response = await fetch(`/api/v1/storefront/products?${params}`);
@@ -188,12 +188,12 @@ document.addEventListener('alpine:init', () => {
const data = await response.json();
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
this.products = data.products;
this.pagination.total = data.total;
} catch (error) {
console.error('[SHOP] Failed to load products:', error);
console.error('[STOREFRONT] Failed to load products:', error);
this.showToast('Failed to load products', 'error');
} finally {
this.loading = false;
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
// formatPrice is inherited from storefrontLayoutData() via spread operator
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
console.log('[STOREFRONT] Adding to cart:', product);
try {
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
@@ -227,16 +227,16 @@ document.addEventListener('alpine:init', () => {
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
console.log('[STOREFRONT] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
console.error('[STOREFRONT] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
console.error('[STOREFRONT] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}

View File

@@ -212,7 +212,7 @@ document.addEventListener('alpine:init', () => {
},
async init() {
console.log('[SHOP] Search page initializing...');
console.log('[STOREFRONT] Search page initializing...');
// Check for query parameter in URL
const urlParams = new URLSearchParams(window.location.search);
@@ -254,7 +254,7 @@ document.addEventListener('alpine:init', () => {
limit: this.perPage
});
console.log(`[SHOP] Searching: /api/v1/storefront/products/search?${params}`);
console.log(`[STOREFRONT] Searching: /api/v1/storefront/products/search?${params}`);
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
@@ -264,12 +264,12 @@ document.addEventListener('alpine:init', () => {
const data = await response.json();
console.log(`[SHOP] Search found ${data.total} results`);
console.log(`[STOREFRONT] Search found ${data.total} results`);
this.products = data.products;
this.total = data.total;
} catch (error) {
console.error('[SHOP] Search failed:', error);
console.error('[STOREFRONT] Search failed:', error);
this.showToast('Search failed. Please try again.', 'error');
this.products = [];
this.total = 0;
@@ -289,7 +289,7 @@ document.addEventListener('alpine:init', () => {
},
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
console.log('[STOREFRONT] Adding to cart:', product);
try {
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
@@ -308,16 +308,16 @@ document.addEventListener('alpine:init', () => {
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
console.log('[STOREFRONT] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
console.error('[STOREFRONT] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
console.error('[STOREFRONT] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}

View File

@@ -143,7 +143,7 @@ document.addEventListener('alpine:init', () => {
isLoggedIn: false,
async init() {
console.log('[SHOP] Wishlist page initializing...');
console.log('[STOREFRONT] Wishlist page initializing...');
// Check if user is logged in
this.isLoggedIn = await this.checkLoginStatus();
@@ -168,7 +168,7 @@ document.addEventListener('alpine:init', () => {
this.loading = true;
try {
console.log('[SHOP] Loading wishlist...');
console.log('[STOREFRONT] Loading wishlist...');
const response = await fetch('/api/v1/storefront/wishlist');
@@ -182,11 +182,11 @@ document.addEventListener('alpine:init', () => {
const data = await response.json();
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
console.log(`[STOREFRONT] Loaded ${data.items?.length || 0} wishlist items`);
this.items = data.items || [];
} catch (error) {
console.error('[SHOP] Failed to load wishlist:', error);
console.error('[STOREFRONT] Failed to load wishlist:', error);
this.showToast('Failed to load wishlist', 'error');
} finally {
this.loading = false;
@@ -195,7 +195,7 @@ document.addEventListener('alpine:init', () => {
async removeFromWishlist(item) {
try {
console.log('[SHOP] Removing from wishlist:', item);
console.log('[STOREFRONT] Removing from wishlist:', item);
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
method: 'DELETE'
@@ -208,13 +208,13 @@ document.addEventListener('alpine:init', () => {
throw new Error('Failed to remove from wishlist');
}
} catch (error) {
console.error('[SHOP] Failed to remove from wishlist:', error);
console.error('[STOREFRONT] Failed to remove from wishlist:', error);
this.showToast('Failed to remove from wishlist', 'error');
}
},
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
console.log('[STOREFRONT] Adding to cart:', product);
try {
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
@@ -233,16 +233,16 @@ document.addEventListener('alpine:init', () => {
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
console.log('[STOREFRONT] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
console.error('[STOREFRONT] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
console.error('[STOREFRONT] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}

View File

@@ -62,6 +62,7 @@ async def admin_login_page(
context = {
"request": request,
"current_language": language,
"frontend_type": "admin",
**get_jinja2_globals(language),
}
return templates.TemplateResponse("tenancy/admin/login.html", context)

View File

@@ -72,6 +72,7 @@ async def merchant_login_page(
context = {
"request": request,
"current_language": language,
"frontend_type": "merchant",
**get_jinja2_globals(language),
}
return templates.TemplateResponse("tenancy/merchant/login.html", context)

View File

@@ -5,11 +5,11 @@
* Works with store-specific themes
*/
const shopLog = {
info: (...args) => console.info('🛒 [SHOP]', ...args),
warn: (...args) => console.warn('⚠️ [SHOP]', ...args),
error: (...args) => console.error('❌ [SHOP]', ...args),
debug: (...args) => console.log('🔍 [SHOP]', ...args)
const shopLog = window.LogConfig?.createLogger('STOREFRONT') || {
info: (...args) => console.info('🛒 [STOREFRONT]', ...args),
warn: (...args) => console.warn('⚠️ [STOREFRONT]', ...args),
error: (...args) => console.error('❌ [STOREFRONT]', ...args),
debug: (...args) => console.log('🔍 [STOREFRONT]', ...args)
};
/**

View File

@@ -149,6 +149,9 @@ def get_context_for_frontend(
# Pass enabled module codes to templates for conditional rendering
context["enabled_modules"] = enabled_module_codes
# Pass frontend type to templates (used by JS for logging, dev toolbar, etc.)
context["frontend_type"] = frontend_type.value
# For storefront, build nav menu structure from module declarations
if frontend_type == FrontendType.STOREFRONT:
from app.modules.core.services.menu_discovery_service import (

View File

@@ -17,10 +17,10 @@ from sqlalchemy import (
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
class Customer(Base, TimestampMixin):
class Customer(Base, TimestampMixin, SoftDeleteMixin):
"""Customer model with store isolation."""
__tablename__ = "customers"

View File

@@ -132,16 +132,17 @@ loyalty_module = ModuleDefinition(
FrontendType.STORE: [
"terminal", # Loyalty terminal
"cards", # Customer cards
"loyalty-program", # Program config
"loyalty-analytics", # Store analytics
"pins", # Staff PINs
"program", # Program config
"analytics", # Store analytics
],
FrontendType.MERCHANT: [
"loyalty-program", # Merchant loyalty program
"loyalty-cards", # Customer cards
"loyalty-analytics", # Merchant loyalty analytics
"loyalty-transactions", # Transaction feed
"loyalty-pins", # Staff PINs
"loyalty-settings", # Settings (read-only)
"program", # Merchant loyalty program
"cards", # Customer cards
"analytics", # Merchant loyalty analytics
"transactions", # Transaction feed
"pins", # Staff PINs
"settings", # Settings (read-only)
],
},
# New module-driven menu definitions
@@ -210,7 +211,7 @@ loyalty_module = ModuleDefinition(
requires_permission="loyalty.view_programs",
),
MenuItemDefinition(
id="loyalty-program",
id="program",
label_key="loyalty.menu.program",
icon="cog",
route="/store/{store_code}/loyalty/program",
@@ -218,7 +219,7 @@ loyalty_module = ModuleDefinition(
requires_permission="loyalty.view_programs",
),
MenuItemDefinition(
id="loyalty-analytics",
id="analytics",
label_key="loyalty.menu.analytics",
icon="chart-bar",
route="/store/{store_code}/loyalty/analytics",
@@ -236,42 +237,42 @@ loyalty_module = ModuleDefinition(
order=60,
items=[
MenuItemDefinition(
id="loyalty-program",
id="program",
label_key="loyalty.menu.program",
icon="gift",
route="/merchants/loyalty/program",
order=10,
),
MenuItemDefinition(
id="loyalty-cards",
id="cards",
label_key="loyalty.menu.customer_cards",
icon="identification",
route="/merchants/loyalty/cards",
order=15,
),
MenuItemDefinition(
id="loyalty-analytics",
id="analytics",
label_key="loyalty.menu.analytics",
icon="chart-bar",
route="/merchants/loyalty/analytics",
order=20,
),
MenuItemDefinition(
id="loyalty-transactions",
id="transactions",
label_key="loyalty.menu.transactions",
icon="clock",
route="/merchants/loyalty/transactions",
order=25,
),
MenuItemDefinition(
id="loyalty-pins",
id="pins",
label_key="loyalty.menu.staff_pins",
icon="key",
route="/merchants/loyalty/pins",
order=30,
),
MenuItemDefinition(
id="loyalty-settings",
id="settings",
label_key="loyalty.menu.settings",
icon="cog",
route="/merchants/loyalty/settings",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ from sqlalchemy import (
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
def generate_card_number() -> str:
@@ -48,7 +48,7 @@ def generate_apple_auth_token() -> str:
return secrets.token_urlsafe(32)
class LoyaltyCard(Base, TimestampMixin):
class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
"""
Customer's loyalty card (PassObject).

View File

@@ -33,7 +33,7 @@ from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
class LoyaltyType(str, enum.Enum):
@@ -44,7 +44,7 @@ class LoyaltyType(str, enum.Enum):
HYBRID = "hybrid" # Both stamps and points
class LoyaltyProgram(Base, TimestampMixin):
class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin):
"""
Merchant's loyalty program configuration.

View File

@@ -393,12 +393,14 @@ class CardService:
query = query.filter(LoyaltyCard.is_active == is_active)
if search:
from sqlalchemy import func
# Normalize search term for card number matching
search_normalized = search.replace("-", "").replace(" ", "")
# Use relationship-based join to avoid direct Customer model import
CustomerModel = LoyaltyCard.customer.property.mapper.class_
query = query.join(LoyaltyCard.customer).filter(
(LoyaltyCard.card_number.replace("-", "").ilike(f"%{search_normalized}%"))
(func.replace(LoyaltyCard.card_number, "-", "").ilike(f"%{search_normalized}%"))
| (CustomerModel.email.ilike(f"%{search}%"))
| (CustomerModel.first_name.ilike(f"%{search}%"))
| (CustomerModel.last_name.ilike(f"%{search}%"))

View File

@@ -568,19 +568,23 @@ class ProgramService:
return program
def delete_program(self, db: Session, program_id: int) -> None:
"""Delete a loyalty program and all associated data."""
"""Soft-delete a loyalty program and associated cards."""
from app.core.soft_delete import soft_delete_cascade
program = self.require_program(db, program_id)
merchant_id = program.merchant_id
# Also delete merchant settings
# Hard delete merchant settings (config data, not business records)
db.query(MerchantLoyaltySettings).filter(
MerchantLoyaltySettings.merchant_id == merchant_id
).delete()
db.delete(program)
soft_delete_cascade(db, program, deleted_by_id=None, cascade_rels=[
("cards", []),
])
db.commit()
logger.info(f"Deleted loyalty program {program_id} for merchant {merchant_id}")
logger.info(f"Soft-deleted loyalty program {program_id} for merchant {merchant_id}")
# =========================================================================
# Merchant Settings

View File

@@ -6,7 +6,7 @@ const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.
function merchantLoyaltyAnalytics() {
return {
...data(),
currentPage: 'loyalty-analytics',
currentPage: 'analytics',
program: null,
locations: [],

View File

@@ -7,7 +7,7 @@ function merchantLoyaltyCardDetail() {
return loyaltyCardDetailView({
apiPrefix: '/merchants/loyalty',
backUrl: '/merchants/loyalty/cards',
currentPage: 'loyalty-cards',
currentPage: 'cards',
});
}

View File

@@ -8,7 +8,7 @@ function merchantLoyaltyCards() {
apiPrefix: '/merchants/loyalty',
baseUrl: '/merchants/loyalty/cards',
showStoreFilter: true,
currentPage: 'loyalty-cards',
currentPage: 'cards',
});
}

View File

@@ -6,7 +6,7 @@ const merchantSettingsViewLog = window.LogConfig.loggers.merchantSettingsView ||
function merchantLoyaltyMerchantSettings() {
return {
...data(),
currentPage: 'loyalty-settings',
currentPage: 'settings',
settings: null,
loading: false,

View File

@@ -8,7 +8,7 @@ function merchantLoyaltyPins() {
apiPrefix: '/merchants/loyalty',
showStoreFilter: true,
showCrud: true,
currentPage: 'loyalty-pins',
currentPage: 'pins',
});
}

View File

@@ -7,7 +7,7 @@ function merchantLoyaltySettings() {
return {
...data(),
...createProgramFormMixin(),
currentPage: 'loyalty-program',
currentPage: 'program',
loading: false,
error: null,

View File

@@ -7,7 +7,7 @@ function merchantLoyaltyTransactions() {
return loyaltyTransactionsList({
apiPrefix: '/merchants/loyalty',
showStoreFilter: true,
currentPage: 'loyalty-transactions',
currentPage: 'transactions',
});
}

View File

@@ -24,6 +24,7 @@ function loyaltyPinsList(config) {
pins: [],
program: null,
locations: [],
staffMembers: [],
// Filters
filters: {
@@ -57,6 +58,53 @@ function loyaltyPinsList(config) {
store_id: ''
},
// Staff autocomplete state
staffSearch: '',
staffSearchResults: [],
showStaffDropdown: false,
searchingStaff: false,
_selectedStaffName: '',
searchStaff() {
// Client-side filter of loaded staff members
if (!this.staffSearch || this.staffSearch.length < 1) {
this.staffSearchResults = [];
this.showStaffDropdown = false;
return;
}
const q = this.staffSearch.toLowerCase();
this.staffSearchResults = this.staffMembers.filter(m =>
(m.full_name && m.full_name.toLowerCase().includes(q)) ||
(m.email && m.email.toLowerCase().includes(q))
);
this.showStaffDropdown = this.staffSearchResults.length > 0;
// If user modified the text away from the selected member, clear staff_id
if (this._selectedStaffName && this.staffSearch !== this._selectedStaffName) {
this.pinForm.staff_id = '';
this._selectedStaffName = '';
}
this.pinForm.name = this.staffSearch;
},
selectStaffMember(item) {
this.pinForm.name = item.full_name;
this.pinForm.staff_id = item.email;
this.staffSearch = item.full_name;
this._selectedStaffName = item.full_name;
this.showStaffDropdown = false;
this.staffSearchResults = [];
},
clearStaffSelection() {
this.staffSearch = '';
this.pinForm.name = '';
this.pinForm.staff_id = '';
this._selectedStaffName = '';
this.showStaffDropdown = false;
this.staffSearchResults = [];
},
// Action state
saving: false,
deleting: false,
@@ -88,6 +136,9 @@ function loyaltyPinsList(config) {
if (config.showStoreFilter) {
parallel.push(this.loadLocations());
}
if (config.showCrud && config.staffApiPrefix) {
parallel.push(this.loadStaffMembers());
}
await Promise.all(parallel);
}
} catch (error) {
@@ -137,6 +188,18 @@ function loyaltyPinsList(config) {
}
},
async loadStaffMembers() {
try {
const response = await apiClient.get(config.staffApiPrefix + '/team/members');
if (response && response.members) {
this.staffMembers = response.members.filter(m => m.is_active);
loyaltyPinsListLog.info('Loaded', this.staffMembers.length, 'staff members');
}
} catch (error) {
loyaltyPinsListLog.warn('Failed to load staff members:', error.message);
}
},
computeStats() {
this.stats.total = this.pins.length;
this.stats.active = this.pins.filter(p => p.is_active && !p.is_locked).length;
@@ -156,6 +219,8 @@ function loyaltyPinsList(config) {
pin: '',
store_id: ''
};
this.staffSearch = '';
this.showStaffDropdown = false;
this.showCreateModal = true;
},
@@ -167,6 +232,8 @@ function loyaltyPinsList(config) {
pin: '',
store_id: pin.store_id || ''
};
this.staffSearch = pin.name || '';
this.showStaffDropdown = false;
this.showEditModal = true;
},

View File

@@ -6,7 +6,7 @@ const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.
function storeLoyaltyAnalytics() {
return {
...data(),
currentPage: 'loyalty-analytics',
currentPage: 'analytics',
program: null,

View File

@@ -6,7 +6,7 @@ const loyaltyCardDetailLog = window.LogConfig.loggers.loyaltyCardDetail || windo
function storeLoyaltyCardDetail() {
return {
...data(),
currentPage: 'loyalty-card-detail',
currentPage: 'cards',
cardId: null,
card: null,

View File

@@ -6,7 +6,7 @@ const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfi
function storeLoyaltyCards() {
return {
...data(),
currentPage: 'loyalty-cards',
currentPage: 'cards',
// Data
cards: [],

View File

@@ -6,7 +6,7 @@ const loyaltyEnrollLog = window.LogConfig.loggers.loyaltyEnroll || window.LogCon
function storeLoyaltyEnroll() {
return {
...data(),
currentPage: 'loyalty-enroll',
currentPage: 'terminal',
program: null,
form: {

View File

@@ -6,6 +6,7 @@ const storePinsLog = window.LogConfig.loggers.storePins || window.LogConfig.crea
function storeLoyaltyPins() {
return loyaltyPinsList({
apiPrefix: '/store/loyalty',
staffApiPrefix: '/store',
showStoreFilter: false,
showCrud: true,
currentPage: 'pins',

View File

@@ -13,7 +13,7 @@ function loyaltySettings() {
...createProgramFormMixin(),
// Page identifier
currentPage: 'loyalty-program',
currentPage: 'program',
// State
loading: false,

View File

@@ -13,7 +13,7 @@ function storeLoyaltyTerminal() {
...data(),
// Page identifier
currentPage: 'loyalty-terminal',
currentPage: 'terminal',
// Program state
program: null,
@@ -23,6 +23,10 @@ function storeLoyaltyTerminal() {
searchQuery: '',
lookingUp: false,
selectedCard: null,
searchResults: [],
showSearchDropdown: false,
searchingCustomers: false,
_searchTimeout: null,
// Transaction inputs
earnAmount: null,
@@ -146,6 +150,61 @@ function storeLoyaltyTerminal() {
}
},
// Debounced search for autocomplete suggestions
debouncedSearchCustomers() {
if (this._searchTimeout) clearTimeout(this._searchTimeout);
if (!this.searchQuery || this.searchQuery.length < 2) {
this.searchResults = [];
this.showSearchDropdown = false;
return;
}
this._searchTimeout = setTimeout(() => this.searchCustomers(), 300);
},
async searchCustomers() {
this.searchingCustomers = true;
try {
const params = new URLSearchParams({
search: this.searchQuery,
limit: '5',
is_active: 'true'
});
const response = await apiClient.get(`/store/loyalty/cards?${params}`);
if (response && response.cards) {
this.searchResults = response.cards;
this.showSearchDropdown = this.searchResults.length > 0;
}
} catch (error) {
loyaltyTerminalLog.warn('Search failed:', error.message);
this.searchResults = [];
this.showSearchDropdown = false;
} finally {
this.searchingCustomers = false;
}
},
// Select a customer from autocomplete dropdown
async selectCustomer(card) {
this.showSearchDropdown = false;
this.searchResults = [];
this.lookingUp = true;
try {
// Use the lookup endpoint to get full card details
const response = await apiClient.get(`/store/loyalty/cards/lookup?q=${encodeURIComponent(card.card_number)}`);
if (response) {
this.selectedCard = response;
this.searchQuery = '';
loyaltyTerminalLog.info('Customer selected:', this.selectedCard.customer_name);
}
} catch (error) {
Utils.showToast(I18n.t('loyalty.store.terminal.error_lookup', {message: error.message}), 'error');
loyaltyTerminalLog.error('Lookup failed:', error);
} finally {
this.lookingUp = false;
}
},
// Clear selected customer
clearCustomer() {
this.selectedCard = null;
@@ -225,7 +284,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Adding stamp...');
await apiClient.post('/store/loyalty/stamp', {
card_id: this.selectedCard.id,
card_id: this.selectedCard.card_id,
staff_pin: this.pinDigits
});
@@ -237,7 +296,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming stamps...');
await apiClient.post('/store/loyalty/stamp/redeem', {
card_id: this.selectedCard.id,
card_id: this.selectedCard.card_id,
staff_pin: this.pinDigits
});
@@ -249,7 +308,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
const response = await apiClient.post('/store/loyalty/points/earn', {
card_id: this.selectedCard.id,
card_id: this.selectedCard.card_id,
purchase_amount_cents: Math.round(this.earnAmount * 100),
staff_pin: this.pinDigits
});
@@ -268,7 +327,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
await apiClient.post('/store/loyalty/points/redeem', {
card_id: this.selectedCard.id,
card_id: this.selectedCard.card_id,
reward_id: this.selectedReward,
staff_pin: this.pinDigits
});
@@ -281,7 +340,7 @@ function storeLoyaltyTerminal() {
// Refresh card data
async refreshCard() {
try {
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.id}`);
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.card_id}`);
if (response) {
this.selectedCard = response;
}

View File

@@ -9,6 +9,7 @@
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import modal, confirm_modal %}
{% from 'shared/macros/inputs.html' import search_autocomplete %}
<!-- Stats Summary -->
<div x-show="!loading" class="mb-6 flex flex-wrap items-center gap-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
@@ -102,11 +103,11 @@
<td class="px-4 py-3">
{% if show_crud %}
<div class="flex items-center gap-2">
<button @click="openEditPin(pin)"
<button @click="openEditModal(pin)"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-sm">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<button @click="confirmDeletePin(pin)"
<button @click="openDeleteModal(pin)"
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
@@ -124,8 +125,6 @@
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% if show_crud %}
@@ -133,17 +132,30 @@
{% call modal('createPinModal', _('loyalty.shared.pins.create_pin'), 'showCreateModal', size='md', show_footer=false) %}
<form @submit.prevent="createPin()">
<div class="space-y-4">
<!-- Staff Member Autocomplete -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_name') }}</label>
<input type="text" x-model="pinForm.name" required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="{{ _('loyalty.shared.pins.pin_name') }}">
{{ search_autocomplete(
search_var='staffSearch',
results_var='staffSearchResults',
show_dropdown_var='showStaffDropdown',
loading_var='searchingStaff',
search_action='searchStaff()',
select_action='selectStaffMember(item)',
display_field='full_name',
secondary_field='email',
placeholder=_('loyalty.shared.pins.pin_name'),
min_chars=1,
no_results_text=_('loyalty.shared.pins.no_staff_found'),
loading_text=_('loyalty.common.loading')
) }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_staff_id') }}</label>
<input type="text" x-model="pinForm.staff_id" required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="{{ _('loyalty.shared.pins.pin_staff_id') }}">
<input type="text" x-model="pinForm.staff_id"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800"
placeholder="{{ _('loyalty.shared.pins.pin_staff_id') }}"
:readonly="pinForm.staff_id && staffMembers.length > 0">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_code') }}</label>
@@ -182,21 +194,20 @@
{% call modal('editPinModal', _('loyalty.shared.pins.edit_pin'), 'showEditModal', size='md', show_footer=false) %}
<form @submit.prevent="updatePin()">
<div class="space-y-4">
<!-- Staff Member Autocomplete -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_name') }}</label>
<input type="text" x-model="pinForm.name" required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="{{ _('loyalty.shared.pins.pin_name') }}">
<input type="text" :value="pinForm.name" readonly
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_staff_id') }}</label>
<input type="text" x-model="pinForm.staff_id" required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="{{ _('loyalty.shared.pins.pin_staff_id') }}">
<input type="text" :value="pinForm.staff_id" readonly
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_code') }}</label>
<input type="password" x-model="pinForm.pin" minlength="4" maxlength="8"
<input type="password" x-model="pinForm.pin" required minlength="4" maxlength="8"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="{{ _('loyalty.shared.pins.pin_edit_placeholder') }}">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _('loyalty.shared.pins.pin_edit_hint') }}</p>
@@ -204,13 +215,8 @@
{% if show_store_filter %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.shared.pins.pin_store') }}</label>
<select x-model="pinForm.store_id" required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ _('loyalty.shared.pins.select_store') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
<input type="text" :value="locations.find(l => l.store_id == pinForm.store_id)?.store_name || '-'" readonly
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 cursor-not-allowed">
</div>
{% endif %}
</div>

View File

@@ -50,20 +50,7 @@
<template x-for="tx in transactions" :key="tx.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3 text-sm" x-text="formatDateTime(tx.transaction_at)"></td>
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
<span class="absolute inset-0 flex items-center justify-center text-xs font-semibold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="tx.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div>
<p class="font-semibold" x-text="tx.customer_name || 'Unknown'"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="tx.card_number || '-'"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm" x-text="tx.customer_name || 'Unknown'"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="{

View File

@@ -61,7 +61,7 @@
function storeLoyaltyProgram() {
return {
...data(),
currentPage: 'loyalty-program',
currentPage: 'program',
program: null,
loading: false,

View File

@@ -3,6 +3,7 @@
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% from 'shared/macros/inputs.html' import search_autocomplete %}
{% block title %}{{ _('loyalty.store.terminal.title') }}{% endblock %}
@@ -63,18 +64,22 @@
</h3>
</div>
<div class="p-4">
<!-- Search Input -->
<div class="relative mb-4">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="searchQuery"
@keyup.enter="lookupCustomer()"
placeholder="{{ _('loyalty.store.terminal.search_placeholder') }}"
class="w-full pl-10 pr-4 py-3 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<!-- Search Input with Autocomplete -->
<div class="mb-4">
{{ search_autocomplete(
search_var='searchQuery',
results_var='searchResults',
show_dropdown_var='showSearchDropdown',
loading_var='searchingCustomers',
search_action='debouncedSearchCustomers()',
select_action='selectCustomer(item)',
display_field='customer_name',
secondary_field='customer_email',
placeholder=_('loyalty.store.terminal.search_placeholder'),
min_chars=2,
no_results_text=_('loyalty.store.terminal.customer_not_found'),
loading_text=_('loyalty.store.terminal.looking_up')
) }}
</div>
<button
@click="lookupCustomer()"

View File

@@ -404,10 +404,10 @@ def get_platform_email_config(db: Session) -> dict:
config["smtp_password"] = db_smtp_password if db_smtp_password else settings.smtp_password
db_smtp_use_tls = get_db_setting("smtp_use_tls")
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else settings.smtp_use_tls
config["smtp_use_tls"] = bool(db_smtp_use_tls) if db_smtp_use_tls is not None else settings.smtp_use_tls
db_smtp_use_ssl = get_db_setting("smtp_use_ssl")
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else settings.smtp_use_ssl
config["smtp_use_ssl"] = bool(db_smtp_use_ssl) if db_smtp_use_ssl is not None else settings.smtp_use_ssl
# SendGrid
db_sendgrid_key = get_db_setting("sendgrid_api_key")
@@ -432,10 +432,10 @@ def get_platform_email_config(db: Session) -> dict:
# Behavior
db_enabled = get_db_setting("email_enabled")
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else settings.email_enabled
config["enabled"] = bool(db_enabled) if db_enabled is not None else settings.email_enabled
db_debug = get_db_setting("email_debug")
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else settings.email_debug
config["debug"] = bool(db_debug) if db_debug is not None else settings.email_debug
return config
@@ -1038,8 +1038,8 @@ class EmailService:
subscription_service,
)
tier = subscription_service.get_current_tier(self.db, store_id)
self._store_tier_cache[store_id] = tier.value if tier else None
sub = subscription_service.get_subscription_for_store(self.db, store_id)
self._store_tier_cache[store_id] = sub.tier.code if sub and sub.tier else None
return self._store_tier_cache[store_id]
def _should_add_powered_by_footer(self, store_id: int | None) -> bool:

View File

@@ -37,10 +37,10 @@ from sqlalchemy.orm import relationship
from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
class Order(Base, TimestampMixin):
class Order(Base, TimestampMixin, SoftDeleteMixin):
"""
Unified order model for all sales channels.

View File

@@ -12,20 +12,45 @@
"team": "Team"
},
"team": {
"title": "Team",
"members": "Mitglieder",
"actions": "Aktionen",
"active": "Aktiv",
"add_member": "Mitglied hinzufügen",
"all_stores": "Alle Filialen",
"edit_member": "Mitglied bearbeiten",
"editor": "Bearbeiter",
"email": "E-Mail",
"email_placeholder": "E-Mail-Adresse eingeben",
"error_title": "Fehler beim Laden des Teams",
"first_name": "Vorname",
"invite_first_member": "Laden Sie Ihr erstes Teammitglied ein",
"invite_member": "Mitglied einladen",
"invitation_accepted": "Einladung angenommen",
"invitation_sent": "Einladung gesendet",
"last_name": "Nachname",
"loading_team": "Team wird geladen...",
"manage_members_description": "Teammitglieder über alle Filialen verwalten",
"manager": "Manager",
"member": "Mitglied",
"member_stores": "Filialen des Mitglieds",
"members": "Mitglieder",
"no_members_description": "Laden Sie Teammitglieder ein um Ihre Filialen zu verwalten",
"no_members_title": "Noch keine Teammitglieder",
"no_role": "Keine Rolle",
"owner": "Inhaber",
"pending_invitations": "Ausstehende Einladungen",
"permissions": "Berechtigungen",
"remove_confirmation": "Sind Sie sicher, dass Sie entfernen möchten",
"remove_from_all_stores": "Aus allen Filialen entfernen",
"remove_member": "Mitglied entfernen",
"role": "Rolle",
"owner": "Inhaber",
"manager": "Manager",
"editor": "Bearbeiter",
"viewer": "Betrachter",
"permissions": "Berechtigungen",
"pending_invitations": "Ausstehende Einladungen",
"invitation_sent": "Einladung gesendet",
"invitation_accepted": "Einladung angenommen"
"select_stores": "Filialen auswählen",
"send_invitation": "Einladung senden",
"status": "Status",
"store_roles": "Filialrollen",
"stores_and_roles": "Filialen & Rollen",
"title": "Team",
"total_members": "Mitglieder gesamt",
"viewer": "Betrachter"
},
"messages": {
"business_info_saved": "Business info saved",

View File

@@ -12,20 +12,45 @@
"team": "Team"
},
"team": {
"title": "Team",
"members": "Members",
"actions": "Actions",
"active": "Active",
"add_member": "Add Member",
"all_stores": "All Stores",
"edit_member": "Edit Member",
"editor": "Editor",
"email": "Email",
"email_placeholder": "Enter email address",
"error_title": "Error loading team",
"first_name": "First Name",
"invite_first_member": "Invite your first team member",
"invite_member": "Invite Member",
"invitation_accepted": "Invitation Accepted",
"invitation_sent": "Invitation Sent",
"last_name": "Last Name",
"loading_team": "Loading team...",
"manage_members_description": "Manage team members across all your stores",
"manager": "Manager",
"member": "Member",
"member_stores": "Member's stores",
"members": "Members",
"no_members_description": "Invite team members to help manage your stores",
"no_members_title": "No team members yet",
"no_role": "No role",
"owner": "Owner",
"pending_invitations": "Pending Invitations",
"permissions": "Permissions",
"remove_confirmation": "Are you sure you want to remove",
"remove_from_all_stores": "Remove from all stores",
"remove_member": "Remove Member",
"role": "Role",
"owner": "Owner",
"manager": "Manager",
"editor": "Editor",
"viewer": "Viewer",
"permissions": "Permissions",
"pending_invitations": "Pending Invitations",
"invitation_sent": "Invitation Sent",
"invitation_accepted": "Invitation Accepted"
"select_stores": "Select Stores",
"send_invitation": "Send Invitation",
"status": "Status",
"store_roles": "Store Roles",
"stores_and_roles": "Stores & Roles",
"title": "Team",
"total_members": "Total Members",
"viewer": "Viewer"
},
"messages": {
"business_info_saved": "Business info saved",

View File

@@ -12,20 +12,45 @@
"team": "Équipe"
},
"team": {
"title": "Équipe",
"members": "Membres",
"actions": "Actions",
"active": "Actifs",
"add_member": "Ajouter un membre",
"all_stores": "Tous les magasins",
"edit_member": "Modifier le membre",
"editor": "Éditeur",
"email": "E-mail",
"email_placeholder": "Saisir l'adresse e-mail",
"error_title": "Erreur lors du chargement",
"first_name": "Prénom",
"invite_first_member": "Invitez votre premier membre",
"invite_member": "Inviter un membre",
"invitation_accepted": "Invitation acceptée",
"invitation_sent": "Invitation envoyée",
"last_name": "Nom de famille",
"loading_team": "Chargement de l'équipe...",
"manage_members_description": "Gérer les membres de l'équipe sur tous vos magasins",
"manager": "Gestionnaire",
"member": "Membre",
"member_stores": "Magasins du membre",
"members": "Membres",
"no_members_description": "Invitez des membres pour gérer vos magasins",
"no_members_title": "Aucun membre encore",
"no_role": "Aucun rôle",
"owner": "Propriétaire",
"pending_invitations": "Invitations en attente",
"permissions": "Permissions",
"remove_confirmation": "Êtes-vous sûr de vouloir supprimer",
"remove_from_all_stores": "Supprimer de tous les magasins",
"remove_member": "Retirer un membre",
"role": "Rôle",
"owner": "Propriétaire",
"manager": "Gestionnaire",
"editor": "Éditeur",
"viewer": "Lecteur",
"permissions": "Permissions",
"pending_invitations": "Invitations en attente",
"invitation_sent": "Invitation envoyée",
"invitation_accepted": "Invitation acceptée"
"select_stores": "Sélectionner les magasins",
"send_invitation": "Envoyer l'invitation",
"status": "Statut",
"store_roles": "Rôles par magasin",
"stores_and_roles": "Magasins et rôles",
"title": "Équipe",
"total_members": "Membres totaux",
"viewer": "Lecteur"
},
"messages": {
"business_info_saved": "Business info saved",

View File

@@ -12,20 +12,45 @@
"team": "Team"
},
"team": {
"title": "Team",
"members": "Memberen",
"actions": "Aktiounen",
"active": "Aktiv",
"add_member": "Member derbäisetzen",
"all_stores": "All Geschäfter",
"edit_member": "Member änneren",
"editor": "Editeur",
"email": "E-Mail",
"email_placeholder": "E-Mail-Adress aginn",
"error_title": "Feeler beim Lueden vum Team",
"first_name": "Virnumm",
"invite_first_member": "Invitéiert Äert éischt Teammember",
"invite_member": "Member invitéieren",
"invitation_accepted": "Invitatioun ugeholl",
"invitation_sent": "Invitatioun geschéckt",
"last_name": "Nonumm",
"loading_team": "Team gëtt gelueden...",
"manage_members_description": "Teammemberen iwwer all Geschäfter verwalten",
"manager": "Manager",
"member": "Member",
"member_stores": "Geschäfter vum Member",
"members": "Memberen",
"no_members_description": "Invitéiert Teammemberen fir Är Geschäfter ze verwalten",
"no_members_title": "Nach keng Teammemberen",
"no_role": "Keng Roll",
"owner": "Proprietär",
"pending_invitations": "Aussteesend Invitatiounen",
"permissions": "Rechter",
"remove_confirmation": "Sidd Dir sécher, datt Dir ewechhuele wëllt",
"remove_from_all_stores": "Vun all Geschäfter ewechhuelen",
"remove_member": "Member ewechhuelen",
"role": "Roll",
"owner": "Proprietär",
"manager": "Manager",
"editor": "Editeur",
"viewer": "Betruechter",
"permissions": "Rechter",
"pending_invitations": "Aussteesend Invitatiounen",
"invitation_sent": "Invitatioun geschéckt",
"invitation_accepted": "Invitatioun ugeholl"
"select_stores": "Geschäfter wielen",
"send_invitation": "Invitatioun schécken",
"status": "Status",
"store_roles": "Geschäftsrollen",
"stores_and_roles": "Geschäfter & Rollen",
"title": "Team",
"total_members": "Memberen total",
"viewer": "Betruechter"
},
"messages": {
"business_info_saved": "Business info saved",

View File

@@ -10,10 +10,10 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
class Merchant(Base, TimestampMixin):
class Merchant(Base, TimestampMixin, SoftDeleteMixin):
"""
Represents a merchant (business entity) in the system.
@@ -74,7 +74,7 @@ class Merchant(Base, TimestampMixin):
# ========================================================================
# Relationships
# ========================================================================
owner = relationship("User", back_populates="owned_merchants")
owner = relationship("User", foreign_keys="[Merchant.owner_user_id]", back_populates="owned_merchants")
"""The user who owns this merchant."""
stores = relationship(

View File

@@ -14,6 +14,7 @@ from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
@@ -24,13 +25,17 @@ from app.core.config import settings
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
class Store(Base, TimestampMixin):
class Store(Base, TimestampMixin, SoftDeleteMixin):
"""Represents a store in the system."""
__tablename__ = "stores" # Name of the table in the database
__table_args__ = (
Index("uq_stores_store_code_active", "store_code", unique=True, postgresql_where="deleted_at IS NULL"),
Index("uq_stores_subdomain_active", "subdomain", unique=True, postgresql_where="deleted_at IS NULL"),
)
id = Column(
Integer, primary_key=True, index=True
@@ -42,11 +47,11 @@ class Store(Base, TimestampMixin):
) # Foreign key to the parent merchant
store_code = Column(
String, unique=True, index=True, nullable=False
) # Unique, indexed, non-nullable store code column
String, index=True, nullable=False
) # Indexed, non-nullable store code column (unique among non-deleted)
subdomain = Column(
String(100), unique=True, nullable=False, index=True
) # Unique, non-nullable subdomain column with indexing
String(100), nullable=False, index=True
) # Non-nullable subdomain column (unique among non-deleted)
name = Column(
String, nullable=False
) # Non-nullable name column for the store (brand name)
@@ -418,7 +423,7 @@ class Store(Base, TimestampMixin):
}
class StoreUser(Base, TimestampMixin):
class StoreUser(Base, TimestampMixin, SoftDeleteMixin):
"""
Represents a user's team membership in a store.

View File

@@ -15,11 +15,11 @@ ROLE SYSTEM (Phase 1 — Consolidated 4-value enum):
import enum
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
from models.database.base import SoftDeleteMixin, TimestampMixin
class UserRole(str, enum.Enum):
@@ -31,14 +31,18 @@ class UserRole(str, enum.Enum):
STORE_MEMBER = "store_member" # Team member on specific store(s)
class User(Base, TimestampMixin):
class User(Base, TimestampMixin, SoftDeleteMixin):
"""Represents a platform user (admins, merchant owners, and store team)."""
__tablename__ = "users"
__table_args__ = (
Index("uq_users_email_active", "email", unique=True, postgresql_where="deleted_at IS NULL"),
Index("uq_users_username_active", "username", unique=True, postgresql_where="deleted_at IS NULL"),
)
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, index=True, nullable=False)
username = Column(String, index=True, nullable=False)
first_name = Column(String)
last_name = Column(String)
hashed_password = Column(String, nullable=False)
@@ -57,7 +61,7 @@ class User(Base, TimestampMixin):
# Relationships
# NOTE: marketplace_import_jobs relationship removed - owned by marketplace module
# Use: MarketplaceImportJob.query.filter_by(user_id=user.id) instead
owned_merchants = relationship("Merchant", back_populates="owner")
owned_merchants = relationship("Merchant", foreign_keys="[Merchant.owner_user_id]", back_populates="owner")
store_memberships = relationship(
"StoreUser", foreign_keys="[StoreUser.user_id]", back_populates="user"
)

View File

@@ -124,6 +124,8 @@ def get_all_merchants(
search: str | None = Query(None, description="Search by merchant name"),
is_active: bool | None = Query(None),
is_verified: bool | None = Query(None),
include_deleted: bool = Query(False, description="Include soft-deleted merchants"),
only_deleted: bool = Query(False, description="Show only soft-deleted merchants (trash view)"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
@@ -135,6 +137,8 @@ def get_all_merchants(
search=search,
is_active=is_active,
is_verified=is_verified,
include_deleted=include_deleted,
only_deleted=only_deleted,
)
return MerchantListResponse(
@@ -403,3 +407,24 @@ def delete_merchant(
db.commit() # ✅ ARCH: Commit at API level for transaction control
return {"message": f"Merchant {merchant_id} deleted successfully"}
@admin_merchants_router.put("/{merchant_id}/restore")
def restore_merchant(
merchant_id: int = Path(..., description="Merchant ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Restore a soft-deleted merchant (Admin only).
This only restores the merchant record itself.
Stores and their children must be restored separately.
"""
from app.core.soft_delete import restore
from app.modules.tenancy.models import Merchant
restored = restore(db, Merchant, merchant_id, restored_by_id=current_admin.id)
db.commit()
logger.info(f"Merchant {merchant_id} restored by admin {current_admin.username}")
return {"message": f"Merchant '{restored.name}' restored successfully", "merchant_id": merchant_id}

View File

@@ -87,6 +87,8 @@ def get_all_stores_admin(
is_active: bool | None = Query(None),
is_verified: bool | None = Query(None),
merchant_id: int | None = Query(None, description="Filter by merchant ID"),
include_deleted: bool = Query(False, description="Include soft-deleted stores"),
only_deleted: bool = Query(False, description="Show only soft-deleted stores (trash view)"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
@@ -99,6 +101,8 @@ def get_all_stores_admin(
is_active=is_active,
is_verified=is_verified,
merchant_id=merchant_id,
include_deleted=include_deleted,
only_deleted=only_deleted,
)
return StoreListResponse(stores=stores, total=total, skip=skip, limit=limit)
@@ -309,3 +313,24 @@ def delete_store(
message = admin_service.delete_store(db, store.id)
db.commit()
return {"message": message}
@admin_stores_router.put("/{store_id}/restore")
def restore_store(
store_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Restore a soft-deleted store (Admin only).
This only restores the store record itself.
Child records (products, customers, etc.) must be restored separately.
"""
from app.core.soft_delete import restore
from app.modules.tenancy.models import Store
restored = restore(db, Store, store_id, restored_by_id=current_admin.id)
db.commit()
logger.info(f"Store {store_id} restored by admin {current_admin.username}")
return {"message": f"Store '{restored.name}' restored successfully", "store_id": store_id}

View File

@@ -143,6 +143,8 @@ def list_admin_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
include_super_admins: bool = Query(True),
include_deleted: bool = Query(False, description="Include soft-deleted users"),
only_deleted: bool = Query(False, description="Show only soft-deleted users (trash view)"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_super_admin),
):
@@ -156,6 +158,8 @@ def list_admin_users(
skip=skip,
limit=limit,
include_super_admins=include_super_admins,
include_deleted=include_deleted,
only_deleted=only_deleted,
)
admin_responses = [_build_admin_response(admin) for admin in admins]
@@ -395,3 +399,26 @@ def delete_admin_user(
"message": "Admin user deleted successfully",
"user_id": user_id,
}
@admin_users_router.put("/{user_id}/restore")
def restore_admin_user(
user_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_super_admin_api),
):
"""
Restore a soft-deleted admin user.
Super admin only.
"""
from app.core.soft_delete import restore
from app.modules.tenancy.models import User
restored = restore(db, User, user_id, restored_by_id=current_admin.id)
db.commit()
return {
"message": f"User '{restored.username}' restored successfully",
"user_id": user_id,
}

View File

@@ -25,6 +25,7 @@ from app.modules.tenancy.schemas import (
MerchantStoreUpdate,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.team import MerchantTeamInvite
from app.modules.tenancy.services.merchant_service import merchant_service
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
@@ -168,11 +169,130 @@ async def merchant_team_overview(
db: Session = Depends(get_db),
):
"""
Get team members across all stores owned by the merchant.
Get team members across all merchant stores (member-centric view).
Returns a list of stores with their team members grouped by store.
Returns deduplicated members with per-store role info.
"""
return merchant_store_service.get_merchant_team_overview(db, merchant.id)
return merchant_store_service.get_merchant_team_members(db, merchant.id)
@_account_router.get("/team/stores/{store_id}/roles")
async def merchant_team_store_roles(
store_id: int,
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Get available roles for a specific store."""
from app.modules.tenancy.services.store_team_service import store_team_service
merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
roles = store_team_service.get_store_roles(db, store_id)
return {"roles": roles, "total": len(roles)}
@_account_router.post("/team/invite")
async def merchant_team_invite(
data: MerchantTeamInvite,
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Invite a member to one or more merchant stores."""
from app.modules.tenancy.schemas.team import (
MerchantTeamInviteResponse,
MerchantTeamInviteResult,
)
from app.modules.tenancy.services.store_team_service import store_team_service
# Get the User ORM object (service needs it as inviter)
inviter = merchant_store_service.get_user(db, current_user.id)
results = []
for store_id in data.store_ids:
try:
store = merchant_store_service.validate_store_ownership(
db, merchant.id, store_id
)
store_team_service.invite_team_member(
db,
store=store,
inviter=inviter,
email=data.email,
role_name=data.role_name,
)
results.append(MerchantTeamInviteResult(
store_id=store.id,
store_name=store.name,
success=True,
))
except Exception as e:
results.append(MerchantTeamInviteResult(
store_id=store_id,
store_name=getattr(e, "store_name", str(store_id)),
success=False,
error=str(e),
))
db.commit()
success_count = sum(1 for r in results if r.success)
if success_count == len(results):
message = f"Invitation sent to {data.email} for {success_count} store(s)"
elif success_count > 0:
message = f"Invitation partially sent ({success_count}/{len(results)} stores)"
else:
message = "Invitation failed for all stores"
return MerchantTeamInviteResponse(
message=message,
email=data.email,
results=results,
)
@_account_router.put("/team/stores/{store_id}/members/{user_id}")
async def merchant_team_update_role(
store_id: int,
user_id: int,
role_name: str = Query(..., description="New role name"),
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Update a member's role in a specific store."""
from app.modules.tenancy.services.store_team_service import store_team_service
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
store_team_service.update_member_role(
db,
store=store,
user_id=user_id,
new_role_name=role_name,
actor_user_id=current_user.id,
)
db.commit()
return {"message": "Role updated successfully"}
@_account_router.delete("/team/stores/{store_id}/members/{user_id}", status_code=204)
async def merchant_team_remove_member(
store_id: int,
user_id: int,
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Remove a member from a specific store."""
from app.modules.tenancy.services.store_team_service import store_team_service
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
store_team_service.remove_team_member(
db,
store=store,
user_id=user_id,
actor_user_id=current_user.id,
)
db.commit()
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)

View File

@@ -277,12 +277,13 @@ def update_team_member(
"""
store = request.state.store
store_team_service.update_member_role(
store_team_service.update_member(
db=db,
store=store,
user_id=user_id,
new_role_id=update_data.role_id,
role_id=update_data.role_id,
is_active=update_data.is_active,
actor_user_id=current_user.id,
)
db.commit()

View File

@@ -94,6 +94,7 @@ async def store_login_page(
"request": request,
"store_code": store_code,
"platform_code": platform_code,
"frontend_type": "store",
**get_jinja2_globals(language),
},
)

View File

@@ -118,6 +118,12 @@ from app.modules.tenancy.schemas.team import (
InvitationAccept,
InvitationAcceptResponse,
InvitationResponse,
MerchantTeamInvite,
MerchantTeamInviteResponse,
MerchantTeamMemberResponse,
MerchantTeamMemberStoreInfo,
MerchantTeamOverviewResponse,
MerchantTeamStoreInfo,
PermissionCheckRequest,
PermissionCheckResponse,
RoleBase,

View File

@@ -315,3 +315,89 @@ class TeamErrorResponse(BaseModel):
error_code: str
message: str
details: dict | None = None
# ============================================================================
# Merchant Team Schemas (Hub View)
# ============================================================================
class MerchantTeamMemberStoreInfo(BaseModel):
"""A member's role/status in one specific store."""
store_id: int
store_name: str
store_code: str
role_name: str | None = None
role_id: int | None = None
is_active: bool = True
is_pending: bool = False
class MerchantTeamMemberResponse(BaseModel):
"""A team member aggregated across all merchant stores."""
user_id: int
email: EmailStr
first_name: str | None = None
last_name: str | None = None
full_name: str
stores: list[MerchantTeamMemberStoreInfo] = Field(default_factory=list)
is_owner: bool = False
class MerchantTeamStoreInfo(BaseModel):
"""Compact store info for the merchant team overview."""
id: int
name: str
code: str
class MerchantTeamOverviewResponse(BaseModel):
"""Merchant team overview with member-centric view."""
merchant_name: str
stores: list[MerchantTeamStoreInfo]
members: list[MerchantTeamMemberResponse]
total_members: int
total_active: int
total_pending: int
class MerchantTeamInvite(BaseModel):
"""Schema for inviting a member to merchant stores."""
email: EmailStr
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
store_ids: list[int] = Field(..., min_length=1, description="Store IDs to invite to")
role_name: str = Field("staff", description="Role name for all selected stores")
@field_validator("role_name")
@classmethod
def validate_role_name(cls, v):
"""Validate role name is in allowed presets."""
allowed_roles = ["manager", "staff", "support", "viewer", "marketing"]
if v.lower() not in allowed_roles:
raise ValueError(
f"Role name must be one of: {', '.join(allowed_roles)}"
)
return v.lower()
class MerchantTeamInviteResult(BaseModel):
"""Per-store invite result."""
store_id: int
store_name: str
success: bool
error: str | None = None
class MerchantTeamInviteResponse(BaseModel):
"""Response for merchant team invite (multi-store)."""
message: str
email: EmailStr
results: list[MerchantTeamInviteResult]

View File

@@ -443,6 +443,8 @@ class AdminPlatformService:
include_super_admins: bool = True,
is_active: bool | None = None,
search: str | None = None,
include_deleted: bool = False,
only_deleted: bool = False,
) -> tuple[list[User], int]:
"""
List all admin users with optional filtering.
@@ -454,6 +456,8 @@ class AdminPlatformService:
include_super_admins: Whether to include super admins
is_active: Filter by active status
search: Search term for username/email/name
include_deleted: Include soft-deleted users
only_deleted: Show only soft-deleted users
Returns:
Tuple of (list of User objects, total count)
@@ -462,6 +466,12 @@ class AdminPlatformService:
User.role.in_(["super_admin", "platform_admin"])
)
# Soft-delete visibility
if include_deleted or only_deleted:
query = query.execution_options(include_deleted=True)
if only_deleted:
query = query.filter(User.deleted_at.isnot(None))
if not include_super_admins:
query = query.filter(User.role == "platform_admin")

View File

@@ -322,8 +322,10 @@ class AdminService:
owned_count=len(user.owned_merchants),
)
from app.core.soft_delete import soft_delete
username = user.username
db.delete(user)
soft_delete(db, user, deleted_by_id=current_admin_id)
logger.info(f"Admin {current_admin_id} deleted user {username}")
return f"User {username} deleted successfully"
@@ -477,12 +479,20 @@ class AdminService:
is_active: bool | None = None,
is_verified: bool | None = None,
merchant_id: int | None = None,
include_deleted: bool = False,
only_deleted: bool = False,
) -> tuple[list[Store], int]:
"""Get paginated list of all stores with filtering."""
try:
# Eagerly load merchant relationship to avoid N+1 queries
query = db.query(Store).options(joinedload(Store.merchant))
# Soft-delete visibility
if include_deleted or only_deleted:
query = query.execution_options(include_deleted=True)
if only_deleted:
query = query.filter(Store.deleted_at.isnot(None))
# Filter by merchant
if merchant_id is not None:
query = query.filter(Store.merchant_id == merchant_id)
@@ -506,6 +516,10 @@ class AdminService:
# Get total count (without joinedload for performance)
count_query = db.query(Store)
if include_deleted or only_deleted:
count_query = count_query.execution_options(include_deleted=True)
if only_deleted:
count_query = count_query.filter(Store.deleted_at.isnot(None))
if merchant_id is not None:
count_query = count_query.filter(Store.merchant_id == merchant_id)
if search:
@@ -596,17 +610,16 @@ class AdminService:
store = self._get_store_by_id_or_raise(db, store_id)
try:
from app.core.soft_delete import soft_delete_cascade
store_code = store.store_code
# TODO: Delete associated data in correct order
# - Delete orders
# - Delete customers
# - Delete products
# - Delete team members
# - Delete roles
# - Delete import jobs
db.delete(store)
soft_delete_cascade(db, store, deleted_by_id=None, cascade_rels=[
("products", []),
("customers", []),
("orders", []),
("store_users", []),
])
logger.warning(f"Store {store_code} and all associated data deleted")
return f"Store {store_code} successfully deleted"

View File

@@ -148,6 +148,8 @@ class MerchantService:
search: str | None = None,
is_active: bool | None = None,
is_verified: bool | None = None,
include_deleted: bool = False,
only_deleted: bool = False,
) -> tuple[list[Merchant], int]:
"""
Get paginated list of merchants with optional filters.
@@ -159,15 +161,25 @@ class MerchantService:
search: Search term for merchant name
is_active: Filter by active status
is_verified: Filter by verified status
include_deleted: Include soft-deleted merchants
only_deleted: Show only soft-deleted merchants (trash view)
Returns:
Tuple of (merchants list, total count)
"""
exec_opts = {}
if include_deleted or only_deleted:
exec_opts["include_deleted"] = True
query = select(Merchant).options(
joinedload(Merchant.stores),
joinedload(Merchant.owner),
)
# Soft-delete filter
if only_deleted:
query = query.where(Merchant.deleted_at.isnot(None))
# Apply filters
if search:
query = query.where(Merchant.name.ilike(f"%{search}%"))
@@ -178,13 +190,13 @@ class MerchantService:
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = db.execute(count_query).scalar()
total = db.execute(count_query, execution_options=exec_opts).scalar()
# Apply pagination and order
query = query.order_by(Merchant.name).offset(skip).limit(limit)
# Use unique() when using joinedload with collections to avoid duplicate rows
merchants = list(db.execute(query).scalars().unique().all())
merchants = list(db.execute(query, execution_options=exec_opts).scalars().unique().all())
return merchants, total
@@ -228,11 +240,19 @@ class MerchantService:
Raises:
MerchantNotFoundException: If merchant not found
"""
from app.core.soft_delete import soft_delete_cascade
merchant = self.get_merchant_by_id(db, merchant_id)
# Due to cascade="all, delete-orphan", associated stores will be deleted
db.delete(merchant)
db.flush()
MERCHANT_CASCADE = [
("stores", [
("products", []),
("customers", []),
("orders", []),
("store_users", []),
]),
]
soft_delete_cascade(db, merchant, deleted_by_id=None, cascade_rels=MERCHANT_CASCADE)
logger.info(f"Deleted merchant ID {merchant_id} and associated stores")
def toggle_verification(

View File

@@ -424,13 +424,126 @@ class MerchantStoreService:
result.append(store_team)
return {
"merchant_name": merchant.business_name or merchant.brand_name,
"merchant_name": merchant.name,
"owner_email": merchant.owner.email if merchant.owner else None,
"stores": result,
"total_members": sum(s["member_count"] for s in result),
}
def get_user(self, db: Session, user_id: int):
"""Get a User ORM object by ID."""
from app.modules.tenancy.models import User
return db.query(User).filter(User.id == user_id).first()
def validate_store_ownership(
self, db: Session, merchant_id: int, store_id: int
) -> Store:
"""
Validate that a store belongs to the merchant.
Returns the Store object if valid, raises exception otherwise.
"""
store = (
db.query(Store)
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
from app.modules.tenancy.exceptions import StoreNotFoundException
raise StoreNotFoundException(store_id, identifier_type="id")
return store
def get_merchant_team_members(self, db: Session, merchant_id: int) -> dict:
"""
Get team members across all merchant stores in a member-centric view.
Deduplicates users across stores and aggregates per-store role info.
"""
from app.modules.tenancy.models.store import StoreUser
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise MerchantNotFoundException(merchant_id)
stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.order_by(Store.name)
.all()
)
# Build member-centric view: keyed by user_id
members_map: dict[int, dict] = {}
store_list = []
for store in stores:
store_list.append({
"id": store.id,
"name": store.name,
"code": store.store_code,
})
store_users = (
db.query(StoreUser)
.filter(StoreUser.store_id == store.id)
.all()
)
for su in store_users:
user = su.user
if not user:
continue
uid = user.id
is_pending = su.invitation_accepted_at is None and su.invitation_token is not None
if uid not in members_map:
members_map[uid] = {
"user_id": uid,
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or user.email,
"stores": [],
"is_owner": uid == merchant.owner_user_id,
}
members_map[uid]["stores"].append({
"store_id": store.id,
"store_name": store.name,
"store_code": store.store_code,
"role_name": su.role.name if su.role else None,
"role_id": su.role_id,
"is_active": su.is_active,
"is_pending": is_pending,
})
members = list(members_map.values())
# Owner first, then alphabetical
members.sort(key=lambda m: (not m["is_owner"], m["full_name"].lower()))
total_active = sum(
1 for m in members
if any(s["is_active"] and not s["is_pending"] for s in m["stores"])
)
total_pending = sum(
1 for m in members
if any(s["is_pending"] for s in m["stores"])
)
return {
"merchant_name": merchant.name,
"stores": store_list,
"members": members,
"total_members": len(members),
"total_active": total_active,
"total_pending": total_pending,
}
# Singleton instance
merchant_store_service = MerchantStoreService()

View File

@@ -76,10 +76,22 @@ class StoreTeamService:
Dict with invitation details
"""
try:
# Check team size limit from subscription
from app.modules.billing.services import subscription_service
# Check team size limit from subscription (skip if no subscription)
try:
from app.modules.billing.services.usage_service import usage_service
subscription_service.check_team_limit(db, store.id)
limit_check = usage_service.check_limit(db, store.id, "team_members")
if limit_check.limit is not None and not limit_check.can_proceed:
raise TierLimitExceededException(
message=limit_check.message or "Team member limit reached",
limit_type="team_members",
current=limit_check.current,
limit=limit_check.limit,
)
except TierLimitExceededException:
raise
except Exception as e: # noqa: EXC003
logger.warning(f"Could not check team limit (proceeding): {e}")
# Check if user already exists
user = db.query(User).filter(User.email == email).first()
@@ -331,8 +343,9 @@ class StoreTeamService:
if store_user.is_owner:
raise CannotRemoveOwnerException(user_id, store.id)
# Soft delete - just deactivate
store_user.is_active = False
from app.core.soft_delete import soft_delete
soft_delete(db, store_user, deleted_by_id=actor_user_id)
logger.info(f"Removed user {user_id} from store {store.store_code}")
@@ -438,6 +451,60 @@ class StoreTeamService:
logger.error(f"Error updating member role: {str(e)}")
raise
def update_member(
self,
db: Session,
store: Store,
user_id: int,
role_id: int | None = None,
is_active: bool | None = None,
actor_user_id: int | None = None,
) -> StoreUser:
"""
Update a team member's role (by ID) and/or active status.
Args:
db: Database session
store: Store
user_id: User ID
role_id: New role ID (must belong to this store)
is_active: New active status
actor_user_id: Actor performing the update
Returns:
Updated StoreUser
"""
store_user = (
db.query(StoreUser)
.filter(StoreUser.store_id == store.id, StoreUser.user_id == user_id)
.first()
)
if not store_user:
raise UserNotFoundException(str(user_id))
if role_id is not None:
role = (
db.query(Role)
.filter(Role.id == role_id, Role.store_id == store.id)
.first()
)
if not role:
raise InvalidRoleException(f"Role {role_id} not found in store {store.id}")
self.update_member_role(
db=db,
store=store,
user_id=user_id,
new_role_name=role.name,
actor_user_id=actor_user_id,
)
if is_active is not None:
store_user.is_active = is_active
db.flush()
db.refresh(store_user)
return store_user
def get_team_members(
self,
db: Session,

View File

@@ -0,0 +1,309 @@
// static/merchant/js/merchant-team.js
/**
* Merchant team management page logic
* Manage team members across stores, invitations, and roles
*/
const merchantTeamLog = window.LogConfig.createLogger('merchantTeam');
merchantTeamLog.info('Loading...');
function merchantTeam() {
merchantTeamLog.info('merchantTeam() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'team',
// Team data
members: [],
stores: [],
stats: { total_members: 0, total_active: 0, total_pending: 0 },
// Loading states
loading: false,
error: null,
saving: false,
// Filters
storeFilter: '',
// Modal states
showInviteModal: false,
showEditModal: false,
showRemoveModal: false,
selectedMember: null,
// Invite form
inviteForm: {
email: '',
first_name: '',
last_name: '',
store_ids: [],
role_name: 'staff',
},
// Role options (preset)
roleOptions: [
{ value: 'manager', label: 'Manager' },
{ value: 'staff', label: 'Staff' },
{ value: 'support', label: 'Support' },
{ value: 'viewer', label: 'Viewer' },
{ value: 'marketing', label: 'Marketing' },
],
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
merchantTeamLog.info('Team init() called');
// Guard against multiple initialization
if (window._merchantTeamInitialized) {
merchantTeamLog.warn('Already initialized, skipping');
return;
}
window._merchantTeamInitialized = true;
// Call parent init first
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Load dynamic menu
this.loadMenuConfig();
try {
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Init failed:', error);
this.error = 'Failed to initialize team page';
}
merchantTeamLog.info('Team initialization complete');
},
/**
* Load team data (members, stores, stats)
*/
async loadTeamData() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.get('/merchants/account/team');
this.members = response.members || [];
this.stores = response.stores || [];
this.stats = {
total_members: response.total_members || 0,
total_active: response.total_active || 0,
total_pending: response.total_pending || 0,
};
merchantTeamLog.info('Loaded team data:', this.members.length, 'members,', this.stores.length, 'stores');
} catch (error) {
merchantTeamLog.error('Failed to load team data:', error);
this.error = error.message || 'Failed to load team data';
} finally {
this.loading = false;
}
},
/**
* Filter members by store
*/
get filteredMembers() {
if (!this.storeFilter) {
return this.members;
}
const storeId = parseInt(this.storeFilter);
return this.members.filter(member =>
member.stores && member.stores.some(s => s.store_id === storeId)
);
},
/**
* Open invite modal with reset form
*/
openInviteModal() {
this.inviteForm = {
email: '',
first_name: '',
last_name: '',
store_ids: this.stores.map(s => s.id),
role_name: 'staff',
};
this.showInviteModal = true;
},
/**
* Toggle store in invite form store_ids
*/
toggleStoreSelection(storeId) {
const idx = this.inviteForm.store_ids.indexOf(storeId);
if (idx > -1) {
this.inviteForm.store_ids.splice(idx, 1);
} else {
this.inviteForm.store_ids.push(storeId);
}
},
/**
* Send invitation
*/
async sendInvitation() {
if (!this.inviteForm.email) {
Utils.showToast(I18n.t('tenancy.messages.email_is_required'), 'error');
return;
}
if (this.inviteForm.store_ids.length === 0) {
Utils.showToast(I18n.t('tenancy.messages.select_at_least_one_store'), 'error');
return;
}
this.saving = true;
try {
await apiClient.post('/merchants/account/team/invite', this.inviteForm);
Utils.showToast(I18n.t('tenancy.messages.invitation_sent_successfully'), 'success');
merchantTeamLog.info('Invitation sent to:', this.inviteForm.email);
this.showInviteModal = false;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to send invitation:', error);
Utils.showToast(error.message || 'Failed to send invitation', 'error');
} finally {
this.saving = false;
}
},
/**
* Open edit modal for a member
*/
openEditModal(member) {
this.selectedMember = JSON.parse(JSON.stringify(member));
this.showEditModal = true;
},
/**
* Update member role for a specific store
*/
async updateMemberRole(storeId, userId, roleName) {
this.saving = true;
try {
await apiClient.put(
`/merchants/account/team/stores/${storeId}/members/${userId}?role_name=${encodeURIComponent(roleName)}`
);
Utils.showToast(I18n.t('tenancy.messages.team_member_updated'), 'success');
merchantTeamLog.info('Updated member role:', userId, 'store:', storeId, 'role:', roleName);
this.showEditModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to update member role:', error);
Utils.showToast(error.message || 'Failed to update member role', 'error');
} finally {
this.saving = false;
}
},
/**
* Open remove confirmation modal
*/
openRemoveModal(member) {
this.selectedMember = JSON.parse(JSON.stringify(member));
this.showRemoveModal = true;
},
/**
* Remove member from a specific store
*/
async removeMember(storeId, userId) {
this.saving = true;
try {
await apiClient.delete(`/merchants/account/team/stores/${storeId}/members/${userId}`);
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
merchantTeamLog.info('Removed member:', userId, 'from store:', storeId);
this.showRemoveModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to remove member:', error);
Utils.showToast(error.message || 'Failed to remove member', 'error');
} finally {
this.saving = false;
}
},
/**
* Remove member from all stores
*/
async removeFromAllStores(member) {
if (!member || !member.stores || member.stores.length === 0) return;
this.saving = true;
try {
for (const store of member.stores) {
await apiClient.delete(
`/merchants/account/team/stores/${store.store_id}/members/${member.user_id}`
);
}
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
merchantTeamLog.info('Removed member from all stores:', member.user_id);
this.showRemoveModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to remove member from all stores:', error);
Utils.showToast(error.message || 'Failed to remove member', 'error');
} finally {
this.saving = false;
}
},
/**
* Get initials for avatar display
*/
getInitials(member) {
const first = member.first_name || member.email?.charAt(0) || '';
const last = member.last_name || '';
return (first.charAt(0) + last.charAt(0)).toUpperCase() || '?';
},
/**
* Get member status based on their store memberships
*/
getMemberStatus(member) {
if (!member.stores || member.stores.length === 0) return 'inactive';
if (member.stores.some(s => s.is_pending)) return 'pending';
if (member.stores.some(s => s.is_active)) return 'active';
return 'inactive';
},
/**
* Format date for display
*/
formatDate(dateStr) {
if (!dateStr) return '-';
const locale = window.STORE_CONFIG?.locale || 'en-GB';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
};
}

View File

@@ -194,7 +194,7 @@ function storeTeam() {
this.saving = true;
try {
await apiClient.put(
`/store/${this.storeCode}/team/members/${this.selectedMember.user_id}`,
`/store/team/members/${this.selectedMember.id}`,
this.editForm
);
@@ -228,7 +228,7 @@ function storeTeam() {
this.saving = true;
try {
await apiClient.delete(`/store/team/members/${this.selectedMember.user_id}`);
await apiClient.delete(`/store/team/members/${this.selectedMember.id}`);
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
storeTeamLog.info('Removed team member:', this.selectedMember.user_id);

View File

@@ -177,6 +177,9 @@
<!-- Scripts - ORDER MATTERS! -->
<!-- 0. Frontend type -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("admin") }}';</script>
<!-- 1. Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>

View File

@@ -178,6 +178,9 @@
<!-- Scripts - ORDER MATTERS! -->
<!-- 0. Frontend type -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("merchant") }}';</script>
<!-- 1. Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>

View File

@@ -1,140 +1,379 @@
{# app/modules/tenancy/templates/tenancy/merchant/team.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import modal, confirm_modal %}
{% block title %}{{ _("tenancy.team.title") }}{% endblock %}
{% block title %}{{ _('tenancy.team.title') }}{% endblock %}
{% block alpine_data %}merchantTeam(){% endblock %}
{% block content %}
<div x-data="merchantTeam()">
<!-- Page Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<!-- Header with Invite button -->
{% call page_header_flex(title=_('tenancy.team.title'), subtitle=_('tenancy.team.manage_members_description')) %}
<button @click="openInviteModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2')"></span>
{{ _('tenancy.team.invite_member') }}
</button>
{% endcall %}
<!-- Loading State -->
{{ loading_state(_('tenancy.team.loading_team'), 'loading') }}
<!-- Error State -->
{{ error_state(_('tenancy.team.error_title'), 'error', 'error && !loading') }}
<!-- Main Content (visible when not loading) -->
<div x-show="!loading" x-cloak>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Total Members -->
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("tenancy.team.title") }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ _("tenancy.team.members") }}
<span x-show="data" class="font-medium" x-text="`(${data?.total_members || 0})`"></span>
</p>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('tenancy.team.total_members') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_members"></p>
</div>
</div>
<!-- Active -->
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('tenancy.team.active') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_active"></p>
</div>
</div>
<!-- Pending -->
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('tenancy.team.pending_invitations') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_pending"></p>
</div>
</div>
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
<!-- Store Filter -->
<div class="mb-4" x-show="stores.length > 1">
<select x-model="storeFilter"
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600">
<option value="">{{ _('tenancy.team.all_stores') }}</option>
<template x-for="store in stores" :key="store.id">
<option :value="store.id" x-text="store.name"></option>
</template>
</select>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ _("common.loading") }}
<!-- Members Table -->
<div x-show="filteredMembers.length > 0">
{% call table_wrapper() %}
{{ table_header([_('tenancy.team.member'), _('tenancy.team.stores_and_roles'), _('tenancy.team.status'), _('tenancy.team.actions')]) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="member in filteredMembers" :key="member.user_id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Member: Avatar + Name + Email -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative w-8 h-8 mr-3 rounded-full flex-shrink-0">
<div class="flex items-center justify-center w-full h-full rounded-full"
:class="getMemberStatus(member) === 'active' ? 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'">
<span class="text-xs font-semibold" x-text="getInitials(member)"></span>
</div>
</div>
<div>
<p class="font-semibold text-gray-800 dark:text-gray-200">
<span x-text="member.first_name || ''"></span>
<span x-text="member.last_name || ''"></span>
<span x-show="!member.first_name && !member.last_name" x-text="member.email"></span>
</p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="member.email"
x-show="member.first_name || member.last_name"></p>
</div>
</div>
</td>
<!-- Stores & Roles -->
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<template x-for="store in member.stores" :key="store.store_id">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
<span class="font-medium" x-text="store.store_name"></span>:
<span x-text="store.role_name || '{{ _('tenancy.team.no_role') }}'"></span>
</span>
</template>
</div>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<template x-if="member.is_owner">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<span x-html="$icon('shield-check', 'w-3 h-3 mr-1')"></span>
{{ _('tenancy.team.owner') }}
</span>
</template>
<template x-if="!member.is_owner && getMemberStatus(member) === 'pending'">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
{{ _('common.pending') }}
</span>
</template>
<template x-if="!member.is_owner && getMemberStatus(member) === 'active'">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{{ _('common.active') }}
</span>
</template>
<template x-if="!member.is_owner && getMemberStatus(member) === 'inactive'">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ _('common.inactive') }}
</span>
</template>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-sm">
<template x-if="member.is_owner">
<span class="inline-flex items-center px-2 py-1 text-xs text-purple-600 dark:text-purple-400">
<span x-html="$icon('shield-check', 'w-4 h-4')"></span>
</span>
</template>
<template x-if="!member.is_owner">
<div class="flex items-center gap-2">
<button @click="openEditModal(member)"
class="p-1.5 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
:title="$t('tenancy.team.edit_member')">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<button @click="openRemoveModal(member)"
class="p-1.5 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
:title="$t('tenancy.team.remove_member')">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
</template>
</td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
<!-- Store Teams -->
<div x-show="!loading && data" x-cloak class="space-y-6">
<template x-for="store in data?.stores || []" :key="store.store_id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Store Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<span x-html="$icon('shopping-bag', 'w-5 h-5 text-gray-400')"></span>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="store.store_name"></h3>
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
</div>
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="store.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="store.is_active ? '{{ _("common.active") }}' : '{{ _("common.inactive") }}'">
</span>
<!-- Empty State -->
<div x-show="!loading && filteredMembers.length === 0" x-cloak
class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<span x-html="$icon('user-group', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
<h3 class="mt-4 text-sm font-medium text-gray-900 dark:text-gray-200">{{ _('tenancy.team.no_members_title') }}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _('tenancy.team.no_members_description') }}</p>
<button @click="openInviteModal()"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('user-plus', 'w-4 h-4 mr-2')"></span>
{{ _('tenancy.team.invite_first_member') }}
</button>
</div>
</div>
<!-- ==================== INVITE MODAL ==================== -->
{% call modal('inviteModal', _('tenancy.team.invite_member'), 'showInviteModal', size='md', show_footer=false) %}
<form @submit.prevent="sendInvitation()" class="space-y-4">
<!-- Email -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _('tenancy.team.email') }} <span class="text-red-500">*</span>
</label>
<input type="email" x-model="inviteForm.email" required
class="w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
placeholder="{{ _('tenancy.team.email_placeholder') }}">
</div>
<!-- First Name / Last Name -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _('tenancy.team.first_name') }}
</label>
<input type="text" x-model="inviteForm.first_name"
class="w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
placeholder="{{ _('tenancy.team.first_name') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _('tenancy.team.last_name') }}
</label>
<input type="text" x-model="inviteForm.last_name"
class="w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
placeholder="{{ _('tenancy.team.last_name') }}">
</div>
</div>
<!-- Store Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('tenancy.team.select_stores') }}
</label>
<div class="space-y-2 max-h-40 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<template x-for="store in stores" :key="store.id">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
:value="store.id"
:checked="inviteForm.store_ids.includes(store.id)"
@change="toggleStoreSelection(store.id)"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="store.name"></span>
<span class="text-xs text-gray-400 font-mono" x-text="store.code"></span>
</label>
</template>
</div>
</div>
<!-- Role -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _('tenancy.team.role') }}
</label>
<select x-model="inviteForm.role_name"
class="w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600">
<template x-for="role in roleOptions" :key="role.value">
<option :value="role.value" x-text="role.label"></option>
</template>
</select>
</div>
<!-- Submit -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" @click="showInviteModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ _('common.cancel') }}
</button>
<button type="submit" :disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-show="!saving" x-html="$icon('paper-airplane', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? '{{ _('common.sending') }}...' : '{{ _('tenancy.team.send_invitation') }}'"></span>
</button>
</div>
</form>
{% endcall %}
<!-- ==================== EDIT MODAL ==================== -->
{% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %}
<div x-show="selectedMember" class="space-y-4">
<!-- Member info (read-only) -->
<div class="flex items-center gap-3 pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300"
x-text="selectedMember ? getInitials(selectedMember) : ''"></span>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">
<span x-text="selectedMember?.first_name || ''"></span>
<span x-text="selectedMember?.last_name || ''"></span>
<span x-show="!selectedMember?.first_name && !selectedMember?.last_name"
x-text="selectedMember?.email"></span>
</p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedMember?.email"
x-show="selectedMember?.first_name || selectedMember?.last_name"></p>
</div>
</div>
<!-- Per-store role management -->
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.store_roles') }}</h4>
<template x-for="store in selectedMember?.stores || []" :key="store.store_id">
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="store.store_name"></p>
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="`${store.member_count} {{ _("tenancy.team.members").toLowerCase() }}`"></span>
<a :href="`/store/${store.store_code}/team`"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors">
<span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
{{ _("common.view") }}
</a>
<select x-model="store.role_name"
class="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none">
<template x-for="role in roleOptions" :key="role.value">
<option :value="role.value" :selected="role.value === store.role_name" x-text="role.label"></option>
</template>
</select>
<button @click="updateMemberRole(store.store_id, selectedMember.user_id, store.role_name)"
:disabled="saving"
class="px-3 py-1 text-xs font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="saving" x-html="$icon('spinner', 'w-3 h-3')"></span>
<span x-show="!saving">{{ _('common.update') }}</span>
</button>
</div>
</div>
</template>
</div>
<!-- Members List -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Owner Row -->
<div class="px-6 py-3 flex items-center gap-4 bg-gray-50/50 dark:bg-gray-700/30">
<div class="w-8 h-8 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center flex-shrink-0">
<span x-html="$icon('shield-check', 'w-4 h-4 text-indigo-600 dark:text-indigo-400')"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="data?.owner_email || '{{ _("tenancy.team.owner") }}'"></p>
</div>
<span class="px-2 py-0.5 text-xs font-medium bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 rounded-full">{{ _("tenancy.team.owner") }}</span>
</div>
<!-- Team Members -->
<template x-for="member in store.members" :key="member.id">
<div class="px-6 py-3 flex items-center gap-4">
<div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
<span x-html="$icon('user', 'w-4 h-4 text-gray-500 dark:text-gray-400')"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">
<span x-text="member.first_name || ''"></span>
<span x-text="member.last_name || ''"></span>
<span x-show="!member.first_name && !member.last_name" x-text="member.email"></span>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"
x-show="member.first_name || member.last_name"></p>
</div>
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="member.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'"
x-text="member.is_active ? (member.role_name || '{{ _("tenancy.team.members") }}') : '{{ _("common.pending") }}'">
</span>
</div>
</template>
<!-- Empty State -->
<template x-if="store.members.length === 0">
<div class="px-6 py-6 text-center text-sm text-gray-400 dark:text-gray-500">
{{ _("tenancy.team.title") }} - {{ _("common.none") }}
</div>
</template>
</div>
</div>
</template>
<!-- Empty State: No Stores -->
<template x-if="data && data.stores.length === 0">
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<span x-html="$icon('user-group', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ _("common.not_available") }}</p>
</div>
</template>
<!-- Close button -->
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button @click="showEditModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ _('common.close') }}
</button>
</div>
</div>
</div>
{% endcall %}
<!-- ==================== REMOVE MODAL ==================== -->
{% call modal('removeModal', _('tenancy.team.remove_member'), 'showRemoveModal', size='sm', show_footer=false) %}
<div x-show="selectedMember" class="space-y-4">
<!-- Warning -->
<div class="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<span x-html="$icon('exclamation', 'w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5')"></span>
<p class="text-sm text-red-800 dark:text-red-200">
{{ _('tenancy.team.remove_confirmation') }}
<strong x-text="(selectedMember?.first_name || '') + ' ' + (selectedMember?.last_name || selectedMember?.email || '')"></strong>
</p>
</div>
<!-- Store list -->
<div class="space-y-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.member_stores') }}:</p>
<template x-for="store in selectedMember?.stores || []" :key="store.store_id">
<div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center gap-2">
<span x-html="$icon('shopping-bag', 'w-4 h-4 text-gray-400')"></span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="store.store_name"></span>
<span class="text-xs text-gray-400" x-text="'(' + (store.role_name || '{{ _('tenancy.team.no_role') }}') + ')'"></span>
</div>
<button @click="removeMember(store.store_id, selectedMember.user_id)"
:disabled="saving"
class="px-2 py-1 text-xs font-medium text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50">
{{ _('common.remove') }}
</button>
</div>
</template>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<button @click="showRemoveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ _('common.cancel') }}
</button>
<button @click="removeFromAllStores(selectedMember)"
:disabled="saving"
x-show="selectedMember?.stores?.length > 1"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-show="!saving" x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
{{ _('tenancy.team.remove_from_all_stores') }}
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block scripts %}
<script>
function merchantTeam() {
return {
loading: true,
error: null,
data: null,
async init() {
try {
this.data = await apiClient.get('/merchants/tenancy/account/team');
} catch (e) {
this.error = e.message || 'Failed to load team data';
} finally {
this.loading = false;
}
}
};
}
</script>
{% block extra_scripts %}
<script defer src="{{ url_for('tenancy_static', path='merchant/js/merchant-team.js') }}"></script>
{% endblock %}

View File

@@ -208,6 +208,9 @@
<!-- Scripts - ORDER MATTERS! -->
<!-- 0. Frontend type -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
<!-- 1. Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>

View File

@@ -77,7 +77,7 @@
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="member in members" :key="member.user_id">
<template x-for="member in members" :key="member.id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Member Info -->
<td class="px-4 py-3">
@@ -240,7 +240,7 @@
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
>
<template x-for="role in roles" :key="role.id">
<option :value="role.id" x-text="role.name"></option>
<option :value="role.id" :selected="role.id === editForm.role_id" x-text="role.name"></option>
</template>
</select>
</div>

View File

@@ -0,0 +1,434 @@
# app/modules/tenancy/tests/integration/test_store_team_members_api.py
"""
Integration tests for store team member CRUD API endpoints.
Tests the member management endpoints at:
/api/v1/store/team/members
/api/v1/store/team/invite
Authentication: Overrides get_current_store_from_cookie_or_header to return
a UserContext with the correct token_store_id. The test user is the merchant
owner, so all permission checks pass (owner bypass).
"""
import uuid
import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Role, Store, StoreUser, User
from app.modules.tenancy.schemas.auth import UserContext
from main import app
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/store/team"
@pytest.fixture
def member_owner(db):
"""Create a store owner user for member tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"memberowner_{uid}@test.com",
username=f"memberowner_{uid}",
hashed_password=auth.hash_password("memberpass123"),
role="merchant_owner",
is_active=True,
first_name="Owner",
last_name="User",
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def member_merchant(db, member_owner):
"""Create a merchant owned by member_owner."""
merchant = Merchant(
name="Member Test Merchant",
owner_user_id=member_owner.id,
contact_email=member_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def member_store(db, member_merchant):
"""Create a store for member tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=member_merchant.id,
store_code=f"MEMTEST_{uid.upper()}",
subdomain=f"memtest{uid}",
name=f"Member Test Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def member_store_user(db, member_store, member_owner):
"""Create a StoreUser association for the owner."""
store_user = StoreUser(
store_id=member_store.id,
user_id=member_owner.id,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
@pytest.fixture
def member_auth(member_owner, member_store, member_store_user):
"""Override auth dependency to simulate authenticated store owner.
Overrides get_current_store_from_cookie_or_header so that both
require_store_owner and require_store_permission(...) inner functions
receive the correct UserContext. The owner bypass ensures all
permission checks pass.
"""
user_context = UserContext(
id=member_owner.id,
email=member_owner.email,
username=member_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=member_store.id,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
@pytest.fixture
def staff_role(db, member_store):
"""Create a 'staff' role for the store."""
role = Role(
store_id=member_store.id,
name="staff",
permissions=["orders.view", "products.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def manager_role(db, member_store):
"""Create a 'manager' role for the store."""
role = Role(
store_id=member_store.id,
name="manager",
permissions=["orders.view", "orders.edit", "products.view", "products.edit", "team.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def team_member_user(db):
"""Create another user to serve as a team member."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"teammember_{uid}@test.com",
username=f"teammember_{uid}",
hashed_password=auth.hash_password("memberpass123"),
role="store_member",
is_active=True,
first_name="Team",
last_name="Member",
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def team_member(db, member_store, team_member_user, staff_role):
"""Create a StoreUser for team_member_user with staff role."""
store_user = StoreUser(
store_id=member_store.id,
user_id=team_member_user.id,
role_id=staff_role.id,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
# ============================================================================
# GET /team/members
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestListMembers:
"""Tests for GET /api/v1/store/team/members."""
def test_list_members_returns_owner_and_member(
self, client, member_auth, team_member, member_owner, team_member_user
):
"""GET /members returns both owner and team member."""
response = client.get(f"{BASE}/members", headers=member_auth)
assert response.status_code == 200
data = response.json()
member_ids = {m["id"] for m in data["members"]}
assert member_owner.id in member_ids
assert team_member_user.id in member_ids
def test_list_members_response_shape(self, client, member_auth, team_member):
"""Each member in the response has expected fields."""
response = client.get(f"{BASE}/members", headers=member_auth)
assert response.status_code == 200
data = response.json()
assert "members" in data
assert "total" in data
assert "active_count" in data
assert "pending_invitations" in data
member = data["members"][0]
assert "id" in member
assert "email" in member
assert "username" in member
assert "first_name" in member
assert "last_name" in member
assert "full_name" in member
assert "role_name" in member
assert "role_id" in member
assert "permissions" in member
assert "is_active" in member
assert "is_owner" in member
assert "invitation_pending" in member
def test_list_members_stats(
self, client, member_auth, team_member, member_owner
):
"""GET /members returns correct statistics."""
response = client.get(f"{BASE}/members", headers=member_auth)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 2
assert data["active_count"] >= 2
assert data["pending_invitations"] >= 0
# ============================================================================
# GET /team/members/{user_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestGetMember:
"""Tests for GET /api/v1/store/team/members/{user_id}."""
def test_get_member_success(
self, client, member_auth, team_member, team_member_user
):
"""GET /members/{user_id} returns the specific member."""
response = client.get(
f"{BASE}/members/{team_member_user.id}", headers=member_auth
)
assert response.status_code == 200
data = response.json()
assert data["id"] == team_member_user.id
assert data["email"] == team_member_user.email
assert data["role_name"] == "staff"
def test_get_nonexistent_member(self, client, member_auth):
"""GET /members/{user_id} returns 404 for non-existent user."""
response = client.get(f"{BASE}/members/99999", headers=member_auth)
assert response.status_code == 404
# ============================================================================
# PUT /team/members/{user_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestUpdateMember:
"""Tests for PUT /api/v1/store/team/members/{user_id}."""
def test_update_member_role_success(
self, client, member_auth, team_member, team_member_user, manager_role
):
"""PUT /members/{user_id} updates the member's role."""
response = client.put(
f"{BASE}/members/{team_member_user.id}",
headers=member_auth,
json={"role_id": manager_role.id},
)
assert response.status_code == 200
data = response.json()
assert data["role_name"] == "manager"
assert data["role_id"] == manager_role.id
def test_update_member_active_status(
self, client, member_auth, team_member, team_member_user
):
"""PUT /members/{user_id} can deactivate a member."""
response = client.put(
f"{BASE}/members/{team_member_user.id}",
headers=member_auth,
json={"is_active": False},
)
assert response.status_code == 200
data = response.json()
assert data["is_active"] is False
def test_update_owner_rejected(
self, client, member_auth, member_owner, staff_role
):
"""PUT /members/{user_id} rejects changing owner's role."""
response = client.put(
f"{BASE}/members/{member_owner.id}",
headers=member_auth,
json={"role_id": staff_role.id},
)
assert response.status_code in (400, 422)
def test_update_nonexistent_member(self, client, member_auth, staff_role):
"""PUT /members/{user_id} returns 404 for non-existent user."""
response = client.put(
f"{BASE}/members/99999",
headers=member_auth,
json={"role_id": staff_role.id},
)
assert response.status_code == 404
def test_update_with_invalid_role_id(
self, client, member_auth, team_member, team_member_user
):
"""PUT /members/{user_id} returns 422 for non-existent role."""
response = client.put(
f"{BASE}/members/{team_member_user.id}",
headers=member_auth,
json={"role_id": 99999},
)
assert response.status_code == 422
# ============================================================================
# DELETE /team/members/{user_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestRemoveMember:
"""Tests for DELETE /api/v1/store/team/members/{user_id}."""
def test_remove_member_success(
self, client, member_auth, team_member, team_member_user, db
):
"""DELETE /members/{user_id} removes a team member."""
response = client.delete(
f"{BASE}/members/{team_member_user.id}", headers=member_auth
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == team_member_user.id
# Verify member is soft-deleted (deleted_at set, record hidden from normal queries)
db.expire_all()
store_user = (
db.query(StoreUser)
.execution_options(include_deleted=True)
.filter(StoreUser.user_id == team_member_user.id)
.first()
)
assert store_user is not None
assert store_user.deleted_at is not None
def test_remove_owner_rejected(self, client, member_auth, member_owner):
"""DELETE /members/{user_id} rejects removing the owner."""
response = client.delete(
f"{BASE}/members/{member_owner.id}", headers=member_auth
)
assert response.status_code == 400
def test_remove_nonexistent_member(self, client, member_auth):
"""DELETE /members/{user_id} returns 404 for non-existent user."""
response = client.delete(
f"{BASE}/members/99999", headers=member_auth
)
assert response.status_code == 404
# ============================================================================
# POST /team/invite
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestInviteMember:
"""Tests for POST /api/v1/store/team/invite."""
def test_invite_member_success(self, client, member_auth, staff_role):
"""POST /invite creates an invitation for a new email."""
uid = uuid.uuid4().hex[:8]
response = client.post(
f"{BASE}/invite",
headers=member_auth,
json={
"email": f"newinvite_{uid}@test.com",
"role_name": "staff",
"first_name": "New",
"last_name": "Invitee",
},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == f"newinvite_{uid}@test.com"
assert data["invitation_sent"] is True
assert data["role"] == "staff"
def test_invite_duplicate_email(
self, client, member_auth, team_member, team_member_user, staff_role
):
"""POST /invite with existing member email returns error or reactivation."""
response = client.post(
f"{BASE}/invite",
headers=member_auth,
json={
"email": team_member_user.email,
"role_name": "staff",
},
)
# May succeed as reactivation or fail as duplicate
assert response.status_code in (200, 400, 409, 422)

View File

@@ -243,17 +243,29 @@ class TestStoreTeamServiceRemove:
"""Test suite for removing team members."""
def test_remove_team_member_success(self, db, team_store, team_member):
"""Test removing a team member."""
"""Test removing a team member (soft delete)."""
result = store_team_service.remove_team_member(
db=db,
store=team_store,
user_id=team_member.user_id,
)
db.commit()
db.refresh(team_member)
# Verify soft-deleted (hidden from normal queries, visible with include_deleted)
from app.modules.tenancy.models import StoreUser
hidden = db.query(StoreUser).filter(StoreUser.id == team_member.id).first()
assert hidden is None # Filtered out by soft-delete
visible = (
db.query(StoreUser)
.execution_options(include_deleted=True)
.filter(StoreUser.id == team_member.id)
.first()
)
assert visible is not None
assert visible.deleted_at is not None
assert result is True
assert team_member.is_active is False
def test_remove_owner_raises_error(self, db, team_store, store_owner):
"""Test removing owner raises exception."""

View File

@@ -100,6 +100,9 @@
<!-- Core Scripts - ORDER MATTERS! -->
<!-- 0. Frontend type (server-injected, used by log-config and dev-toolbar) -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("admin") }}';</script>
<!-- 1. FIRST: Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>

View File

@@ -50,6 +50,9 @@
<!-- Core Scripts - ORDER MATTERS! -->
<!-- 0. Frontend type (server-injected, used by log-config and dev-toolbar) -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("merchant") }}';</script>
<!-- 1. FIRST: Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>

View File

@@ -54,6 +54,9 @@
<!-- Core Scripts - ORDER MATTERS! -->
<!-- 0. Frontend type (server-injected, used by log-config and dev-toolbar) -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
<!-- 1. FIRST: Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>

View File

@@ -349,6 +349,9 @@
{# JavaScript Loading Order (CRITICAL - must be in this order) #}
{# 0. Frontend type (server-injected, used by log-config and dev-toolbar) #}
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("storefront") }}';</script>
{# 1. Log Configuration (must load first) #}
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>

View File

@@ -79,12 +79,16 @@ def testing_session_local(engine):
commits. This allows fixtures to remain usable after database operations
without needing to refresh or re-query them.
"""
return sessionmaker(
from app.core.database import register_soft_delete_filter
session_factory = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
expire_on_commit=False, # Prevents lazy-load issues after commits
)
register_soft_delete_filter(session_factory)
return session_factory
@pytest.fixture(scope="session", autouse=True)

119
docs/backend/soft-delete.md Normal file
View File

@@ -0,0 +1,119 @@
# Soft Delete
## Overview
Business-critical records use soft delete instead of hard delete. When a record is "deleted", it gets a `deleted_at` timestamp instead of being removed from the database. This preserves data for investigation, auditing, and potential restoration.
## How It Works
### SoftDeleteMixin
Models opt into soft delete by inheriting `SoftDeleteMixin` (from `models/database/base.py`):
```python
from models.database.base import SoftDeleteMixin, TimestampMixin
class MyModel(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "my_table"
# ...
```
This adds two columns:
| Column | Type | Description |
|--------|------|-------------|
| `deleted_at` | DateTime (nullable, indexed) | When the record was deleted. NULL = alive. |
| `deleted_by_id` | Integer (FK to users.id, nullable) | Who performed the deletion. |
### Automatic Query Filtering
A `do_orm_execute` event on the session automatically appends `WHERE deleted_at IS NULL` to all SELECT queries for models with `SoftDeleteMixin`. This means:
- **Normal queries never see deleted records** — no code changes needed
- **Relationship lazy loads are also filtered** — e.g., `store.products` won't include deleted products
### Bypassing the Filter
To see deleted records (admin views, restore operations):
```python
# Legacy query style
db.query(User).execution_options(include_deleted=True).all()
# Core select style
from sqlalchemy import select
db.execute(
select(User).filter(User.id == 42),
execution_options={"include_deleted": True}
).scalar_one_or_none()
```
## Models Using Soft Delete
| Model | Table | Module |
|-------|-------|--------|
| User | users | tenancy |
| Merchant | merchants | tenancy |
| Store | stores | tenancy |
| StoreUser | store_users | tenancy |
| Customer | customers | customers |
| Order | orders | orders |
| Product | products | catalog |
| LoyaltyProgram | loyalty_programs | loyalty |
| LoyaltyCard | loyalty_cards | loyalty |
## Utility Functions
Import from `app.core.soft_delete`:
### `soft_delete(db, entity, deleted_by_id)`
Marks a single record as deleted.
### `restore(db, model_class, entity_id, restored_by_id)`
Restores a soft-deleted record. Queries with `include_deleted=True` internally.
### `soft_delete_cascade(db, entity, deleted_by_id, cascade_rels)`
Soft-deletes a record and recursively soft-deletes its children:
```python
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
("stores", [
("products", []),
("customers", []),
("orders", []),
("store_users", []),
]),
])
```
## Partial Unique Indexes
Tables with unique constraints (e.g., `users.email`, `stores.store_code`) use **partial unique indexes** that only enforce uniqueness among non-deleted rows:
```sql
CREATE UNIQUE INDEX uq_users_email_active ON users (email) WHERE deleted_at IS NULL;
```
This allows a soft-deleted user's email to be reused by a new registration.
## Adding Soft Delete to a New Model
1. Add `SoftDeleteMixin` to the model class
2. Create an alembic migration adding `deleted_at` and `deleted_by_id` columns
3. If the model has unique constraints, convert them to partial unique indexes
4. If the model has relationships to users (ForeignKey to users.id), add `foreign_keys=` to those relationships to resolve ambiguity with `deleted_by_id`
5. Register the test session factory with `register_soft_delete_filter()` if not already done
## What Stays as Hard Delete
Operational and config data that doesn't need investigation trail:
- Roles, themes, email settings, invoice settings
- Cart items, application logs, notifications
- Password/email verification tokens
- Domains (store and merchant)
- Content pages, media files
- Import jobs, marketplace products

View File

@@ -0,0 +1,143 @@
# Post Soft-Delete Follow-up Tasks
**Date:** 2026-03-28
**Context:** During the soft-delete implementation session, several gaps were identified in the platform. This proposal outlines 6 follow-up tasks in priority order.
---
## 1. Admin Email Verification Gap (Quick Fix)
**Problem:** Admin users (super_admin, platform_admin) are created with `is_email_verified=False` (model default). Login checks `is_email_verified` and blocks unverified users. But there's no admin-facing email verification flow — no verification email is sent on admin creation, and `/resend-verification` is merchant-scoped.
**Impact:** Newly created admin accounts can't log in until somehow email-verified.
**Proposed Fix:** Auto-set `is_email_verified=True` when creating admin users via `admin_platform_service.create_super_admin()` and `create_platform_admin()`. Admins are created by super admins, so trust is implicit.
**Alternative:** Send a verification email on admin creation using the existing `EmailVerificationToken` model and `/verify-email` page endpoint.
**Files:**
- `app/modules/tenancy/services/admin_platform_service.py``create_super_admin()`, `create_platform_admin()`
**Effort:** Small (< 30 min)
---
## 2. Customer Soft-Delete Endpoint (Compliance)
**Problem:** Customers have no delete endpoint at all — not soft delete, not hard delete. Only customer addresses can be deleted. This is a gap for GDPR/data-subject-deletion compliance.
**Proposed Fix:** Add soft-delete endpoints:
- `DELETE /api/v1/store/customers/{customer_id}` — store owner/staff can soft-delete
- `DELETE /api/v1/admin/customers/{customer_id}` — admin can soft-delete
Customer already has `SoftDeleteMixin`. Consider cascading to orders, addresses, and loyalty cards.
**Files:**
- `app/modules/customers/routes/api/store.py` — new DELETE endpoint
- `app/modules/customers/services/customer_service.py` — new `delete_customer()` method
**Effort:** Medium (1-2 hours)
---
## 3. Cascade Restore Utility
**Problem:** `restore()` only restores a single record. Restoring a merchant doesn't auto-restore its stores/products/customers/orders. Admin has to restore each entity one by one.
**Proposed Fix:** Add `restore_cascade()` to `app/core/soft_delete.py` mirroring `soft_delete_cascade()`. Walk the same relationship tree. Add optional `cascade=true` query param to existing restore endpoints:
- `PUT /api/v1/admin/merchants/{id}/restore?cascade=true`
- `PUT /api/v1/admin/stores/{id}/restore?cascade=true`
**Files:**
- `app/core/soft_delete.py` — new `restore_cascade()` function
- `app/modules/tenancy/routes/api/admin_stores.py` — update restore endpoint
- `app/modules/tenancy/routes/api/admin_merchants.py` — update restore endpoint
**Effort:** Small-Medium (1 hour)
---
## 4. Admin Trash UI
**Problem:** The soft-delete API supports `?only_deleted=true` on admin list endpoints (stores, merchants, users) but there's no UI to browse or restore deleted records.
**Proposed Fix:** Add a "Trash" toggle/tab to admin list pages:
- `admin/stores.html` — toggle between active stores and trash
- `admin/merchants.html` — same
- `admin/admin-users.html` — same (super admin only)
Each deleted row shows `deleted_at`, `deleted_by`, and a "Restore" button calling `PUT /api/v1/admin/{entity}/{id}/restore`.
**Implementation:** The Alpine.js components need a `showDeleted` toggle state that:
- Adds `?only_deleted=true` to the list API call
- Shows a different table header (with deleted_at column)
- Replaces edit/delete actions with a Restore button
**Files:**
- `app/modules/tenancy/templates/tenancy/admin/stores.html`
- `app/modules/tenancy/templates/tenancy/admin/merchants.html`
- `app/modules/tenancy/templates/tenancy/admin/admin-users.html`
- Corresponding JS files in `app/modules/tenancy/static/admin/js/`
**Effort:** Medium (2-3 hours)
---
## 5. Admin Team Management Page
**Problem:** There is no admin-level page for managing store teams. The admin can see merchant users at `/admin/merchant-users`, but this is a user-centric view — not team-centric. Admin cannot:
- View team members per store
- Invite/remove team members on behalf of a store
- See team composition across the platform
Store owners manage their teams at `/store/{code}/team`. Merchants manage across stores at `/merchants/account/team`. But admin has no equivalent.
**Proposed Fix:** Add `/admin/stores/{store_code}/team` page that reuses the existing store team API endpoints (`/api/v1/store/team/*`) with admin auth context. The admin store detail page should link to it.
**Components needed:**
- Page route in `app/modules/tenancy/routes/pages/admin.py`
- Template at `app/modules/tenancy/templates/tenancy/admin/store-team.html`
- JS component (can largely reuse `store/js/team.js` patterns)
- Menu item or link from store detail page
**Consideration:** Admin already has `/admin/store-roles` for role CRUD. The team page completes the picture.
**Effort:** Medium-Large (3-4 hours)
---
## 6. Merchant Team Roles Page
**Problem:** Store frontend has a full roles management page (`/store/{code}/team/roles`) with CRUD for custom roles and granular permissions. Merchant portal has no equivalent — merchants can only assign preset roles (manager, staff, support, viewer, marketing) during invite/edit, not create custom roles.
**Proposed Fix:** Add `/merchants/account/team/roles` page. Since roles are per-store in the data model, the page should:
1. Let merchant pick a store from a dropdown
2. Show roles for that store (reusing `GET /account/team/stores/{store_id}/roles`)
3. Allow CRUD on custom roles (delegating to store team service)
**Files:**
- New page route in `app/modules/tenancy/routes/pages/merchant.py`
- New template at `app/modules/tenancy/templates/tenancy/merchant/team-roles.html`
- New JS at `app/modules/tenancy/static/merchant/js/merchant-roles.js`
- New API endpoints in `app/modules/tenancy/routes/api/merchant.py`
- Menu item in `app/modules/tenancy/definition.py` (merchant menu)
- i18n keys in 4 locale files
**Reference:** Store roles page at `templates/tenancy/store/roles.html` and `static/store/js/roles.js`
**Effort:** Large (4-5 hours)
---
## Priority & Sequencing
| # | Task | Priority | Effort | Dependency |
|---|------|----------|--------|------------|
| 1 | Admin email verification | Critical | Small | None |
| 2 | Customer soft-delete | High (compliance) | Medium | None |
| 3 | Cascade restore | Medium | Small | None |
| 4 | Admin trash UI | Medium | Medium | None |
| 5 | Admin team management | Medium | Medium-Large | None |
| 6 | Merchant roles page | Low | Large | None |
Tasks 1-3 can be done in a single session. Tasks 4-6 are independent and can be tackled in any order.

View File

@@ -96,6 +96,7 @@ nav:
- Store-in-Token Architecture: backend/store-in-token-architecture.md
- Admin Integration Guide: backend/admin-integration-guide.md
- Admin Feature Integration: backend/admin-feature-integration.md
- Soft Delete: backend/soft-delete.md
# --- Frontend ---
- Frontend:
@@ -332,6 +333,7 @@ nav:
- RBAC Cleanup Two-Phase Plan: proposals/rbac-cleanup-two-phase-plan.md
- Store Login Platform Detection: proposals/store-login-platform-detection.md
- Test API Deps Auth Dependencies: proposals/test-api-deps-auth-dependencies.md
- Post Soft-Delete Follow-ups: proposals/post-soft-delete-followups.md
# --- Archive ---
- Archive:

View File

@@ -5,6 +5,7 @@ Database models package - Base classes and mixins only.
This package provides the base infrastructure for SQLAlchemy models:
- Base: SQLAlchemy declarative base
- TimestampMixin: Mixin for created_at/updated_at timestamps
- SoftDeleteMixin: Mixin for soft-deletable models (deleted_at/deleted_by_id)
IMPORTANT: Domain models have been migrated to their respective modules:
- Tenancy models: app.modules.tenancy.models
@@ -22,9 +23,10 @@ IMPORTANT: Domain models have been migrated to their respective modules:
Import models from their canonical module locations instead of this package.
"""
from .base import Base, TimestampMixin
from .base import Base, SoftDeleteMixin, TimestampMixin
__all__ = [
"Base",
"SoftDeleteMixin",
"TimestampMixin",
]

View File

@@ -1,6 +1,6 @@
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime
from sqlalchemy import Column, DateTime, ForeignKey, Integer
from app.core.database import Base
@@ -15,3 +15,20 @@ class TimestampMixin:
onupdate=datetime.now(UTC),
nullable=False,
)
class SoftDeleteMixin:
"""Mixin for soft-deletable models.
Adds deleted_at and deleted_by_id columns. Records with deleted_at set
are automatically excluded from queries via the do_orm_execute event
in app.core.database. Use execution_options={"include_deleted": True}
to bypass the filter.
"""
deleted_at = Column(DateTime, nullable=True, index=True)
deleted_by_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)

View File

@@ -848,6 +848,7 @@ class ArchitectureValidator:
is_admin = "/admin/" in file_path_str or "\\admin\\" in file_path_str
is_store = "/store/" in file_path_str or "\\store\\" in file_path_str
is_shop = "/shop/" in file_path_str or "\\shop\\" in file_path_str
is_merchant = "/merchant/" in file_path_str or "\\merchant\\" in file_path_str
if is_base_or_partial:
print("⏭️ Skipping base/partial template")
@@ -881,8 +882,8 @@ class ArchitectureValidator:
# TPL-008: Check for call table_header() pattern (should be table_header_custom)
self._check_table_header_call_pattern(file_path, content, lines)
# TPL-009: Check for invalid block names (admin and store use same blocks)
if is_admin or is_store:
# TPL-009: Check for invalid block names (admin, store, and merchant use same blocks)
if is_admin or is_store or is_merchant:
self._check_valid_block_names(file_path, content, lines)
if is_base_or_partial:

View File

@@ -22,6 +22,7 @@
"saving": "Speichern...",
"processing": "Verarbeiten...",
"searching": "Suchen...",
"sending": "Wird gesendet",
"refresh": "Aktualisieren",
"retry": "Erneut versuchen",
"view": "Ansehen",

View File

@@ -22,6 +22,7 @@
"saving": "Saving...",
"processing": "Processing...",
"searching": "Searching...",
"sending": "Sending",
"refresh": "Refresh",
"retry": "Retry",
"view": "View",

View File

@@ -22,6 +22,7 @@
"saving": "Enregistrement...",
"processing": "Traitement...",
"searching": "Recherche...",
"sending": "Envoi en cours",
"refresh": "Actualiser",
"retry": "Réessayer",
"view": "Voir",

View File

@@ -22,6 +22,7 @@
"saving": "Späicheren...",
"processing": "Veraarbechten...",
"searching": "Sichen...",
"sending": "Gëtt geschéckt",
"refresh": "Aktualiséieren",
"retry": "Nach eng Kéier probéieren",
"view": "Kucken",

View File

@@ -127,18 +127,22 @@ class APIClient {
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
// Parse response
// Parse response (handle 204 No Content gracefully)
let data;
try {
data = await response.json();
apiLog.debug('Response data received:', {
hasData: !!data,
dataType: typeof data,
keys: data ? Object.keys(data) : []
});
} catch (parseError) {
apiLog.error('Failed to parse JSON response:', parseError);
throw new Error('Invalid JSON response from server');
if (response.status === 204) {
data = null;
} else {
try {
data = await response.json();
apiLog.debug('Response data received:', {
hasData: !!data,
dataType: typeof data,
keys: data ? Object.keys(data) : []
});
} catch (parseError) {
apiLog.error('Failed to parse JSON response:', parseError);
throw new Error('Invalid JSON response from server');
}
}
// Handle 401 Unauthorized - Just clear tokens, DON'T redirect

View File

@@ -286,11 +286,11 @@
}
function detectFrontend() {
// Prefer server-injected value (set in base templates)
if (window.FRONTEND_TYPE) return window.FRONTEND_TYPE;
// Fallback for pages without base template (e.g., API docs)
var path = window.location.pathname;
if (path.startsWith('/store/') || path === '/store') return 'store';
if (path.startsWith('/admin/') || path === '/admin') return 'admin';
if (path.indexOf('/merchants/') !== -1) return 'merchant';
if (path.indexOf('/storefront/') !== -1) return 'storefront';
if (path.startsWith('/api/')) return 'api';
return 'unknown';
}

View File

@@ -43,14 +43,11 @@ const LOG_LEVELS = {
/**
* Detect which frontend we're in based on URL path
* @returns {string} 'admin' | 'store' | 'shop' | 'unknown'
* @returns {string} 'admin' | 'store' | 'merchant' | 'storefront' | 'unknown'
*/
function detectFrontend() {
const path = window.location.pathname;
if (path.startsWith('/admin')) return 'admin';
if (path.startsWith('/store')) return 'store';
if (path.startsWith('/shop')) return 'shop';
// Prefer server-injected value (set in base templates before this script loads)
if (window.FRONTEND_TYPE) return window.FRONTEND_TYPE;
return 'unknown';
}
@@ -94,9 +91,13 @@ const DEFAULT_LOG_LEVELS = {
development: LOG_LEVELS.DEBUG,
production: LOG_LEVELS.INFO // Stores might need more logging
},
shop: {
merchant: {
development: LOG_LEVELS.DEBUG,
production: LOG_LEVELS.ERROR // Shop frontend: minimal logging in production
production: LOG_LEVELS.INFO // Merchant portal: same as store
},
storefront: {
development: LOG_LEVELS.DEBUG,
production: LOG_LEVELS.ERROR // Storefront: minimal logging in production
},
unknown: {
development: LOG_LEVELS.DEBUG,
@@ -275,10 +276,10 @@ const storeLoggers = {
};
// ============================================================================
// PRE-CONFIGURED LOGGERS FOR SHOP FRONTEND
// PRE-CONFIGURED LOGGERS FOR STOREFRONT
// ============================================================================
const shopLoggers = {
const storefrontLoggers = {
// Product browsing
catalog: createLogger('CATALOG', ACTIVE_LOG_LEVEL),
product: createLogger('PRODUCT', ACTIVE_LOG_LEVEL),
@@ -309,8 +310,10 @@ function getLoggers() {
return adminLoggers;
case 'store':
return storeLoggers;
case 'shop':
return shopLoggers;
case 'merchant':
return storeLoggers; // Merchant portal reuses store logger set
case 'storefront':
return storefrontLoggers;
default:
return {}; // Empty object, use createLogger instead
}

View File

@@ -0,0 +1,296 @@
# tests/unit/core/test_soft_delete.py
"""
Unit tests for soft-delete infrastructure.
Tests the SoftDeleteMixin, automatic query filtering, and utility functions.
"""
import uuid
from datetime import UTC, datetime
import pytest
from app.core.soft_delete import restore, soft_delete, soft_delete_cascade
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sd_owner(db):
"""Create a user for soft-delete tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"sdowner_{uid}@test.com",
username=f"sdowner_{uid}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def sd_merchant(db, sd_owner):
"""Create a merchant for soft-delete tests."""
merchant = Merchant(
name="SD Test Merchant",
owner_user_id=sd_owner.id,
contact_email=sd_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def sd_store(db, sd_merchant):
"""Create a store for soft-delete tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=sd_merchant.id,
store_code=f"SDTEST_{uid.upper()}",
subdomain=f"sdtest{uid}",
name=f"SD Test Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def sd_member_user(db):
"""Create another user to be a store member."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"sdmember_{uid}@test.com",
username=f"sdmember_{uid}",
hashed_password=auth.hash_password("pass123"),
role="store_member",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def sd_store_user(db, sd_store, sd_member_user):
"""Create a StoreUser membership."""
store_user = StoreUser(
store_id=sd_store.id,
user_id=sd_member_user.id,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
# ============================================================================
# SoftDeleteMixin basic behavior
# ============================================================================
@pytest.mark.unit
class TestSoftDeleteBasic:
"""Test soft_delete() utility function."""
def test_soft_delete_sets_fields(self, db, sd_owner, sd_store):
"""soft_delete() sets deleted_at and deleted_by_id."""
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
db.commit()
# Query with include_deleted to see the record
store = (
db.query(Store)
.execution_options(include_deleted=True)
.filter(Store.id == sd_store.id)
.first()
)
assert store is not None
assert store.deleted_at is not None
assert store.deleted_by_id == sd_owner.id
def test_soft_deleted_excluded_from_queries(self, db, sd_owner, sd_store):
"""Soft-deleted records are automatically excluded from normal queries."""
store_id = sd_store.id
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
db.commit()
result = db.query(Store).filter(Store.id == store_id).first()
assert result is None
def test_soft_deleted_visible_with_include_deleted(self, db, sd_owner, sd_store):
"""Soft-deleted records are visible with include_deleted=True."""
store_id = sd_store.id
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
db.commit()
result = (
db.query(Store)
.execution_options(include_deleted=True)
.filter(Store.id == store_id)
.first()
)
assert result is not None
assert result.id == store_id
# ============================================================================
# Restore
# ============================================================================
@pytest.mark.unit
class TestRestore:
"""Test restore() utility function."""
def test_restore_clears_deleted_fields(self, db, sd_owner, sd_store):
"""restore() clears deleted_at and deleted_by_id."""
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
db.commit()
restored = restore(db, Store, sd_store.id, restored_by_id=sd_owner.id)
db.commit()
assert restored.deleted_at is None
assert restored.deleted_by_id is None
def test_restore_makes_record_visible(self, db, sd_owner, sd_store):
"""After restore, record is visible in normal queries."""
store_id = sd_store.id
soft_delete(db, sd_store, deleted_by_id=sd_owner.id)
db.commit()
restore(db, Store, store_id, restored_by_id=sd_owner.id)
db.commit()
result = db.query(Store).filter(Store.id == store_id).first()
assert result is not None
def test_restore_not_deleted_raises(self, db, sd_store):
"""restore() raises ValueError if record is not deleted."""
with pytest.raises(ValueError, match="is not deleted"):
restore(db, Store, sd_store.id, restored_by_id=1)
def test_restore_not_found_raises(self, db):
"""restore() raises ValueError if record doesn't exist."""
with pytest.raises(ValueError, match="not found"):
restore(db, Store, 99999, restored_by_id=1)
# ============================================================================
# Cascade soft delete
# ============================================================================
@pytest.mark.unit
class TestSoftDeleteCascade:
"""Test soft_delete_cascade() utility function."""
def test_cascade_deletes_parent_and_children(
self, db, sd_owner, sd_store, sd_store_user
):
"""soft_delete_cascade() deletes parent and its children."""
count = soft_delete_cascade(
db,
sd_store,
deleted_by_id=sd_owner.id,
cascade_rels=[("store_users", [])],
)
db.commit()
assert count == 2 # store + store_user
# Both should be hidden from normal queries
assert db.query(Store).filter(Store.id == sd_store.id).first() is None
assert (
db.query(StoreUser).filter(StoreUser.id == sd_store_user.id).first()
is None
)
# Both visible with include_deleted
store = (
db.query(Store)
.execution_options(include_deleted=True)
.filter(Store.id == sd_store.id)
.first()
)
assert store is not None
assert store.deleted_at is not None
su = (
db.query(StoreUser)
.execution_options(include_deleted=True)
.filter(StoreUser.id == sd_store_user.id)
.first()
)
assert su is not None
assert su.deleted_at is not None
# ============================================================================
# Partial unique indexes
# ============================================================================
@pytest.mark.unit
class TestPartialUniqueIndexes:
"""Test that unique constraints allow reuse after soft delete."""
def test_user_email_reusable_after_soft_delete(self, db):
"""Soft-deleted user's email can be used by a new user."""
from middleware.auth import AuthManager
auth = AuthManager()
email = f"reuse_{uuid.uuid4().hex[:8]}@test.com"
username = f"reuse_{uuid.uuid4().hex[:8]}"
user1 = User(
email=email,
username=username,
hashed_password=auth.hash_password("pass123"),
role="store_member",
is_active=True,
)
db.add(user1)
db.commit()
# Soft-delete user1
soft_delete(db, user1, deleted_by_id=None)
db.commit()
# Create user2 with same email — should succeed
user2 = User(
email=email,
username=f"reuse2_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="store_member",
is_active=True,
)
db.add(user2)
db.commit()
db.refresh(user2)
assert user2.id is not None
assert user2.email == email