Files
orion/docs/backend/soft-delete.md
Samir Boulahtit 9bceeaac9c feat(arch): implement soft delete for business-critical models
Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:08:07 +01:00

3.7 KiB

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):

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):

# 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:

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:

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