# 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