feat(arch): implement soft delete for business-critical models

Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 21:08:07 +01:00
parent 332960de30
commit 9bceeaac9c
26 changed files with 1069 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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