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(); if (b is null) return NotFound();
b.Name = req.Name; b.Name = req.Name;
b.UpdatedAt = DateTimeOffset.UtcNow; b.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); 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)); 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(); if (!await authz.IsOwnerAsync(id, userId)) return Forbid();
var b = await db.Budgets.FindAsync(id); var b = await db.Budgets.FindAsync(id);
if (b is null) return NotFound(); if (b is null) return NotFound();
db.Budgets.Remove(b); b.IsDeleted = true;
b.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
@@ -86,7 +86,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId); var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
if (income is null) return NotFound(); if (income is null) return NotFound();
db.Incomes.Remove(income); income.IsDeleted = true;
income.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
@@ -104,7 +104,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId); var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
if (outgo is null) return NotFound(); if (outgo is null) return NotFound();
db.Outgos.Remove(outgo); outgo.IsDeleted = true;
outgo.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
@@ -83,7 +83,8 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz)
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid(); if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId); var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId);
if (share is null) return NotFound(); if (share is null) return NotFound();
db.BudgetShares.Remove(share); share.IsDeleted = true;
share.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
+4 -1
View File
@@ -1,6 +1,6 @@
namespace Budget.Core.Models; namespace Budget.Core.Models;
public class Budget public class Budget : ISoftDeletable
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
@@ -8,6 +8,9 @@ public class Budget
public decimal EffectiveTaxRate { get; set; } = 0.25m; public decimal EffectiveTaxRate { get; set; } = 0.25m;
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { 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<Income> Incomes { get; set; } = [];
public List<Outgo> Outgos { get; set; } = []; public List<Outgo> Outgos { get; set; } = [];
+3 -1
View File
@@ -1,6 +1,6 @@
namespace Budget.Core.Models; namespace Budget.Core.Models;
public class BudgetShare public class BudgetShare : ISoftDeletable
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid BudgetId { get; set; } public Guid BudgetId { get; set; }
@@ -9,6 +9,8 @@ public class BudgetShare
public SharePermission Permission { get; set; } public SharePermission Permission { get; set; }
public bool IsPending { get; set; } public bool IsPending { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Budget Budget { get; set; } = null!; 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; namespace Budget.Core.Models;
public class Income public class Income : ISoftDeletable
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid BudgetId { get; set; } public Guid BudgetId { get; set; }
@@ -8,6 +8,8 @@ public class Income
public Frequency Frequency { get; set; } public Frequency Frequency { get; set; }
public decimal Amount { get; set; } public decimal Amount { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Budget Budget { get; set; } = null!; public Budget Budget { get; set; } = null!;
} }
+3 -1
View File
@@ -1,6 +1,6 @@
namespace Budget.Core.Models; namespace Budget.Core.Models;
public class Outgo public class Outgo : ISoftDeletable
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid BudgetId { get; set; } public Guid BudgetId { get; set; }
@@ -12,6 +12,8 @@ public class Outgo
public string? PaymentSource { get; set; } public string? PaymentSource { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Budget Budget { get; set; } = null!; 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.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.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.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 => modelBuilder.Entity<Income>(b =>
@@ -29,6 +35,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
b.HasKey(x => x.Id); b.HasKey(x => x.Id);
b.Property(x => x.Name).IsRequired().HasMaxLength(200); b.Property(x => x.Name).IsRequired().HasMaxLength(200);
b.Property(x => x.Amount).HasPrecision(18, 2); b.Property(x => x.Amount).HasPrecision(18, 2);
b.HasQueryFilter(x => !x.IsDeleted);
}); });
modelBuilder.Entity<Outgo>(b => 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.PaymentSource).HasMaxLength(100);
b.Property(x => x.Notes).HasMaxLength(1000); b.Property(x => x.Notes).HasMaxLength(1000);
b.Property(x => x.Amount).HasPrecision(18, 2); b.Property(x => x.Amount).HasPrecision(18, 2);
b.HasQueryFilter(x => !x.IsDeleted);
}); });
modelBuilder.Entity<KnownUser>(b => 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.SharedWithUserId).HasMaxLength(200);
b.Property(x => x.SharedWithEmail).IsRequired().HasMaxLength(200); b.Property(x => x.SharedWithEmail).IsRequired().HasMaxLength(200);
b.HasIndex(x => new { x.BudgetId, x.SharedWithEmail }).IsUnique(); b.HasIndex(x => new { x.BudgetId, x.SharedWithEmail }).IsUnique();
b.HasQueryFilter(x => !x.IsDeleted);
}); });
} }
} }