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>
120 lines
3.7 KiB
Markdown
120 lines
3.7 KiB
Markdown
# 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
|