Files
budget/.plans/18-soft-delete-concurrency.md
T
Spencer Twaddle 087fbdd176 Split into Budget.Core / Budget.Infrastructure / Budget.Api projects
Budget.Core: entities, DTOs, enums, FrequencyCalculator (no EF/ASP.NET deps)
Budget.Infrastructure: AppDbContext, migrations, BudgetAuthorizationService
Budget.Api: controllers, middleware, Program.cs — references both projects

EF and Npgsql packages moved to Infrastructure; Api retains only JwtBearer,
HealthChecks, and EF.Design (needed for dotnet ef CLI). Dockerfile updated
to copy all three project directories before publishing. Migration namespaces
updated from Budget.Api.Data.* to Budget.Infrastructure.Data.* and model
type strings updated to Budget.Core.Models.* in the snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:30:31 -05:00

107 lines
4.3 KiB
Markdown

# Plan: Soft Delete and Concurrency Tokens
## Goal
Add soft delete (`IsDeleted` / `DeletedAt` + global EF query filter) and a `xmin`-based
concurrency token (`RowVersion`) to the entities that benefit from them, matching the
stwaddle stack convention.
## Current state
- Hard deletes on all entities — `db.X.Remove(entity); await db.SaveChangesAsync()`.
- No concurrency tokens — concurrent updates overwrite each other silently.
- No `IsDeleted` / `DeletedAt` columns.
## Scope decisions
**Soft delete:** Apply to `Budget`, `Income`, `Outgo`, and `BudgetShare`.
`KnownUser` is a provisioning-only table; hard deletes are fine there.
**Concurrency token:** Apply to `Budget` only for now — it is the entity most
likely to be edited by multiple users simultaneously (owner + shared editor).
`Income` and `Outgo` updates are user-local; `BudgetShare` and `KnownUser` are
low-contention. Extend later if needed.
## Steps
### Phase 1 — Update entity models
> If plan #10 (project split) has been implemented, these files live in `Budget.Core/Models/`.
> Otherwise they are in `Budget.Api/Models/`.
1. Add an `ISoftDeletable` interface (or base class) with `bool IsDeleted`, `DateTimeOffset? DeletedAt`.
2. Implement on `Budget`, `Income`, `Outgo`, `BudgetShare`.
3. Add `byte[] RowVersion` (mapped to `xmin`) to `Budget` only.
### Phase 2 — Update AppDbContext / EF configuration
> If plan #10 has been implemented, `AppDbContext` is in `Budget.Infrastructure/Data/`.
4. In `AppDbContext.OnModelCreating`, for each soft-deletable entity add:
```csharp
builder.HasQueryFilter(x => !x.IsDeleted);
```
5. For `Budget`, add the concurrency token config:
```csharp
builder.Property(e => e.RowVersion)
.HasColumnName("xmin")
.HasColumnType("xid")
.ValueGeneratedOnAddOrUpdate()
.IsConcurrencyToken();
```
### Phase 3 — Update delete endpoints to soft delete
6. In each controller, replace `db.X.Remove(entity)` with:
```csharp
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
```
Affected actions: `BudgetsController.Delete`, `IncomesController.Delete`,
`OutgosController.Delete`, `SharesController.Revoke`.
### Phase 4 — Handle concurrency conflicts
7. In `BudgetsController.Update` (and any other Budget write endpoint), wrap
`SaveChangesAsync` in a try/catch for `DbUpdateConcurrencyException` and return
`Conflict(new { error = "The budget was modified by another user. Please refresh and retry." })`.
### Phase 5 — Migration
8. `dotnet ef migrations add AddSoftDeleteAndConcurrency --project Budget.Infrastructure --startup-project Budget.Api`
(adjust project flags if plan #10 has not been implemented yet).
9. Review the generated migration — verify `xmin` column is not added as a normal column
(it should appear only in `modelSnapshot`, not as an `AddColumn` since `xmin` is a Postgres
system column).
10. Apply: `dotnet ef database update` or let startup migrations handle it.
### Phase 6 — Build and verify
11. `dotnet build` — zero errors.
12. Manually verify that a soft-deleted budget no longer appears in `GET /api/budgets`
without any controller changes (query filter handles it).
## Key decisions
- No admin endpoint to view/restore deleted records is in scope here — add `IgnoreQueryFilters()`
to a future admin endpoint if needed.
- `RowVersion` is not exposed in DTOs or returned to the client in this plan.
If optimistic concurrency is needed client-side (e.g., ETag), that is a separate concern.
- Cascade behavior: when a `Budget` is soft-deleted its `Incomes`, `Outgos`, and `BudgetShares`
are not automatically soft-deleted. They are filtered out indirectly because `BudgetsController`
checks budget access before returning child resources. Explicit cascading can be added later.
## Files affected
- `Budget.Core/Models/Budget.cs` (or `Budget.Api/Models/Budget.cs`)
- `Budget.Core/Models/Income.cs`
- `Budget.Core/Models/Outgo.cs`
- `Budget.Core/Models/BudgetShare.cs`
- New: `Budget.Core/Models/ISoftDeletable.cs` (optional interface)
- `Budget.Infrastructure/Data/AppDbContext.cs` (or `Budget.Api/Data/AppDbContext.cs`)
- `Budget.Api/Controllers/BudgetsController.cs`
- `Budget.Api/Controllers/IncomesController.cs`
- `Budget.Api/Controllers/OutgosController.cs`
- `Budget.Api/Controllers/SharesController.cs`
- New migration file