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

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