Compare commits
29 Commits
edd55cd2fd
...
71b5eb1758
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b5eb1758 | |||
| b4f01210d9 | |||
| 9bceeaac9c | |||
| 332960de30 | |||
| 0455e63a2e | |||
| aaed1b2d01 | |||
| 9dee534b2f | |||
| beef3ce76b | |||
| 884a694718 | |||
| 4cafbe9610 | |||
| 19923ed26b | |||
| 46f8d227b8 | |||
| 95e4956216 | |||
| 77e520bbce | |||
| 518bace534 | |||
| fcde2d68fc | |||
| 5a33f68743 | |||
| 040cbd1962 | |||
| b679c9687d | |||
| 314360a394 | |||
| 44a0c38016 | |||
| da9e1ab293 | |||
| 5de297a804 | |||
| 4429674100 | |||
| 316ec42566 | |||
| 894832c62b | |||
| 1d90bfe044 | |||
| ce0caa5685 | |||
| 33f823aba0 |
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal 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")
|
||||
@@ -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
143
app/core/soft_delete.py
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}%"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.
|
||||
function merchantLoyaltyAnalytics() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-analytics',
|
||||
currentPage: 'analytics',
|
||||
|
||||
program: null,
|
||||
locations: [],
|
||||
|
||||
@@ -7,7 +7,7 @@ function merchantLoyaltyCardDetail() {
|
||||
return loyaltyCardDetailView({
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
backUrl: '/merchants/loyalty/cards',
|
||||
currentPage: 'loyalty-cards',
|
||||
currentPage: 'cards',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function merchantLoyaltyCards() {
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
baseUrl: '/merchants/loyalty/cards',
|
||||
showStoreFilter: true,
|
||||
currentPage: 'loyalty-cards',
|
||||
currentPage: 'cards',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const merchantSettingsViewLog = window.LogConfig.loggers.merchantSettingsView ||
|
||||
function merchantLoyaltyMerchantSettings() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-settings',
|
||||
currentPage: 'settings',
|
||||
|
||||
settings: null,
|
||||
loading: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ function merchantLoyaltyPins() {
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
showStoreFilter: true,
|
||||
showCrud: true,
|
||||
currentPage: 'loyalty-pins',
|
||||
currentPage: 'pins',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ function merchantLoyaltySettings() {
|
||||
return {
|
||||
...data(),
|
||||
...createProgramFormMixin(),
|
||||
currentPage: 'loyalty-program',
|
||||
currentPage: 'program',
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -7,7 +7,7 @@ function merchantLoyaltyTransactions() {
|
||||
return loyaltyTransactionsList({
|
||||
apiPrefix: '/merchants/loyalty',
|
||||
showStoreFilter: true,
|
||||
currentPage: 'loyalty-transactions',
|
||||
currentPage: 'transactions',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.
|
||||
function storeLoyaltyAnalytics() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-analytics',
|
||||
currentPage: 'analytics',
|
||||
|
||||
program: null,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfi
|
||||
function storeLoyaltyCards() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-cards',
|
||||
currentPage: 'cards',
|
||||
|
||||
// Data
|
||||
cards: [],
|
||||
|
||||
@@ -6,7 +6,7 @@ const loyaltyEnrollLog = window.LogConfig.loggers.loyaltyEnroll || window.LogCon
|
||||
function storeLoyaltyEnroll() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-enroll',
|
||||
currentPage: 'terminal',
|
||||
|
||||
program: null,
|
||||
form: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -13,7 +13,7 @@ function loyaltySettings() {
|
||||
...createProgramFormMixin(),
|
||||
|
||||
// Page identifier
|
||||
currentPage: 'loyalty-program',
|
||||
currentPage: 'program',
|
||||
|
||||
// State
|
||||
loading: false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="{
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
function storeLoyaltyProgram() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'loyalty-program',
|
||||
currentPage: 'program',
|
||||
|
||||
program: null,
|
||||
loading: false,
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -118,6 +118,12 @@ from app.modules.tenancy.schemas.team import (
|
||||
InvitationAccept,
|
||||
InvitationAcceptResponse,
|
||||
InvitationResponse,
|
||||
MerchantTeamInvite,
|
||||
MerchantTeamInviteResponse,
|
||||
MerchantTeamMemberResponse,
|
||||
MerchantTeamMemberStoreInfo,
|
||||
MerchantTeamOverviewResponse,
|
||||
MerchantTeamStoreInfo,
|
||||
PermissionCheckRequest,
|
||||
PermissionCheckResponse,
|
||||
RoleBase,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
309
app/modules/tenancy/static/merchant/js/merchant-team.js
Normal file
309
app/modules/tenancy/static/merchant/js/merchant-team.js
Normal 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'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
119
docs/backend/soft-delete.md
Normal 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
|
||||
143
docs/proposals/post-soft-delete-followups.md
Normal file
143
docs/proposals/post-soft-delete-followups.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"saving": "Speichern...",
|
||||
"processing": "Verarbeiten...",
|
||||
"searching": "Suchen...",
|
||||
"sending": "Wird gesendet",
|
||||
"refresh": "Aktualisieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"view": "Ansehen",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"saving": "Saving...",
|
||||
"processing": "Processing...",
|
||||
"searching": "Searching...",
|
||||
"sending": "Sending",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"view": "View",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"saving": "Enregistrement...",
|
||||
"processing": "Traitement...",
|
||||
"searching": "Recherche...",
|
||||
"sending": "Envoi en cours",
|
||||
"refresh": "Actualiser",
|
||||
"retry": "Réessayer",
|
||||
"view": "Voir",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
296
tests/unit/core/test_soft_delete.py
Normal file
296
tests/unit/core/test_soft_delete.py
Normal 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
|
||||
Reference in New Issue
Block a user