# 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
