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

@@ -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"
)