Phase 1-4: Add soft delete, concurrency token, update EF config and controllers

- Add ISoftDeletable interface with IsDeleted/DeletedAt
- Implement on Budget, Income, Outgo, BudgetShare; add RowVersion (xmin) to Budget
- Configure EF global query filters and xmin concurrency token
- Replace Remove() with soft delete in all delete endpoints
- Wrap Budget.Update SaveChanges in DbUpdateConcurrencyException catch
This commit is contained in:
Spencer Twaddle
2026-05-02 17:14:28 -05:00
parent da6eb547ce
commit 2908397b1e
10 changed files with 45 additions and 9 deletions
@@ -75,7 +75,14 @@ public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz
if (b is null) return NotFound();
b.Name = req.Name;
b.UpdatedAt = DateTimeOffset.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new { error = "The budget was modified by another user. Please refresh and retry." });
}
return Ok(new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt));
}
@@ -87,7 +94,8 @@ public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz
if (!await authz.IsOwnerAsync(id, userId)) return Forbid();
var b = await db.Budgets.FindAsync(id);
if (b is null) return NotFound();
db.Budgets.Remove(b);
b.IsDeleted = true;
b.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return NoContent();
}
@@ -86,7 +86,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
if (income is null) return NotFound();
db.Incomes.Remove(income);
income.IsDeleted = true;
income.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return NoContent();
}
@@ -104,7 +104,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
if (outgo is null) return NotFound();
db.Outgos.Remove(outgo);
outgo.IsDeleted = true;
outgo.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return NoContent();
}
@@ -83,7 +83,8 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz)
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId);
if (share is null) return NotFound();
db.BudgetShares.Remove(share);
share.IsDeleted = true;
share.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return NoContent();
}
+4 -1
View File
@@ -1,6 +1,6 @@
namespace Budget.Core.Models;
public class Budget
public class Budget : ISoftDeletable
{
public Guid Id { get; set; }
public required string Name { get; set; }
@@ -8,6 +8,9 @@ public class Budget
public decimal EffectiveTaxRate { get; set; } = 0.25m;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public byte[] RowVersion { get; set; } = [];
public List<Income> Incomes { get; set; } = [];
public List<Outgo> Outgos { get; set; } = [];
+3 -1
View File
@@ -1,6 +1,6 @@
namespace Budget.Core.Models;
public class BudgetShare
public class BudgetShare : ISoftDeletable
{
public Guid Id { get; set; }
public Guid BudgetId { get; set; }
@@ -9,6 +9,8 @@ public class BudgetShare
public SharePermission Permission { get; set; }
public bool IsPending { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Budget Budget { get; set; } = null!;
}
+7
View File
@@ -0,0 +1,7 @@
namespace Budget.Core.Models;
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
}
+3 -1
View File
@@ -1,6 +1,6 @@
namespace Budget.Core.Models;
public class Income
public class Income : ISoftDeletable
{
public Guid Id { get; set; }
public Guid BudgetId { get; set; }
@@ -8,6 +8,8 @@ public class Income
public Frequency Frequency { get; set; }
public decimal Amount { get; set; }
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Budget Budget { get; set; } = null!;
}
+3 -1
View File
@@ -1,6 +1,6 @@
namespace Budget.Core.Models;
public class Outgo
public class Outgo : ISoftDeletable
{
public Guid Id { get; set; }
public Guid BudgetId { get; set; }
@@ -12,6 +12,8 @@ public class Outgo
public string? PaymentSource { get; set; }
public string? Notes { get; set; }
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Budget Budget { get; set; } = null!;
}
@@ -22,6 +22,12 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
b.HasMany(x => x.Incomes).WithOne(i => i.Budget).HasForeignKey(i => i.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Outgos).WithOne(o => o.Budget).HasForeignKey(o => o.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Shares).WithOne(s => s.Budget).HasForeignKey(s => s.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
b.Property(e => e.RowVersion)
.HasColumnName("xmin")
.HasColumnType("xid")
.ValueGeneratedOnAddOrUpdate()
.IsConcurrencyToken();
});
modelBuilder.Entity<Income>(b =>
@@ -29,6 +35,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
b.HasKey(x => x.Id);
b.Property(x => x.Name).IsRequired().HasMaxLength(200);
b.Property(x => x.Amount).HasPrecision(18, 2);
b.HasQueryFilter(x => !x.IsDeleted);
});
modelBuilder.Entity<Outgo>(b =>
@@ -39,6 +46,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
b.Property(x => x.PaymentSource).HasMaxLength(100);
b.Property(x => x.Notes).HasMaxLength(1000);
b.Property(x => x.Amount).HasPrecision(18, 2);
b.HasQueryFilter(x => !x.IsDeleted);
});
modelBuilder.Entity<KnownUser>(b =>
@@ -55,6 +63,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
b.Property(x => x.SharedWithUserId).HasMaxLength(200);
b.Property(x => x.SharedWithEmail).IsRequired().HasMaxLength(200);
b.HasIndex(x => new { x.BudgetId, x.SharedWithEmail }).IsUnique();
b.HasQueryFilter(x => !x.IsDeleted);
});
}
}