From 2908397b1e8a68f497b0eb1d30393ce1d72fa8cd Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 2 May 2026 17:14:28 -0500 Subject: [PATCH] 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 --- src/Budget.Api/Controllers/BudgetsController.cs | 12 ++++++++++-- src/Budget.Api/Controllers/IncomesController.cs | 3 ++- src/Budget.Api/Controllers/OutgosController.cs | 3 ++- src/Budget.Api/Controllers/SharesController.cs | 3 ++- src/Budget.Core/Models/Budget.cs | 5 ++++- src/Budget.Core/Models/BudgetShare.cs | 4 +++- src/Budget.Core/Models/ISoftDeletable.cs | 7 +++++++ src/Budget.Core/Models/Income.cs | 4 +++- src/Budget.Core/Models/Outgo.cs | 4 +++- src/Budget.Infrastructure/Data/AppDbContext.cs | 9 +++++++++ 10 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 src/Budget.Core/Models/ISoftDeletable.cs diff --git a/src/Budget.Api/Controllers/BudgetsController.cs b/src/Budget.Api/Controllers/BudgetsController.cs index f094146..d71210d 100644 --- a/src/Budget.Api/Controllers/BudgetsController.cs +++ b/src/Budget.Api/Controllers/BudgetsController.cs @@ -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; - 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)); } @@ -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(); } diff --git a/src/Budget.Api/Controllers/IncomesController.cs b/src/Budget.Api/Controllers/IncomesController.cs index b961019..62f18fd 100644 --- a/src/Budget.Api/Controllers/IncomesController.cs +++ b/src/Budget.Api/Controllers/IncomesController.cs @@ -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(); } diff --git a/src/Budget.Api/Controllers/OutgosController.cs b/src/Budget.Api/Controllers/OutgosController.cs index 24770c7..8481096 100644 --- a/src/Budget.Api/Controllers/OutgosController.cs +++ b/src/Budget.Api/Controllers/OutgosController.cs @@ -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(); } diff --git a/src/Budget.Api/Controllers/SharesController.cs b/src/Budget.Api/Controllers/SharesController.cs index fc973c8..d5bc7ff 100644 --- a/src/Budget.Api/Controllers/SharesController.cs +++ b/src/Budget.Api/Controllers/SharesController.cs @@ -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(); } diff --git a/src/Budget.Core/Models/Budget.cs b/src/Budget.Core/Models/Budget.cs index 8214d7a..c62f66a 100644 --- a/src/Budget.Core/Models/Budget.cs +++ b/src/Budget.Core/Models/Budget.cs @@ -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 Incomes { get; set; } = []; public List Outgos { get; set; } = []; diff --git a/src/Budget.Core/Models/BudgetShare.cs b/src/Budget.Core/Models/BudgetShare.cs index c6e196a..8aafcc9 100644 --- a/src/Budget.Core/Models/BudgetShare.cs +++ b/src/Budget.Core/Models/BudgetShare.cs @@ -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!; } diff --git a/src/Budget.Core/Models/ISoftDeletable.cs b/src/Budget.Core/Models/ISoftDeletable.cs new file mode 100644 index 0000000..699dc61 --- /dev/null +++ b/src/Budget.Core/Models/ISoftDeletable.cs @@ -0,0 +1,7 @@ +namespace Budget.Core.Models; + +public interface ISoftDeletable +{ + bool IsDeleted { get; set; } + DateTimeOffset? DeletedAt { get; set; } +} diff --git a/src/Budget.Core/Models/Income.cs b/src/Budget.Core/Models/Income.cs index 7b1ea16..b3e7866 100644 --- a/src/Budget.Core/Models/Income.cs +++ b/src/Budget.Core/Models/Income.cs @@ -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!; } diff --git a/src/Budget.Core/Models/Outgo.cs b/src/Budget.Core/Models/Outgo.cs index 2997b0a..3952943 100644 --- a/src/Budget.Core/Models/Outgo.cs +++ b/src/Budget.Core/Models/Outgo.cs @@ -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!; } diff --git a/src/Budget.Infrastructure/Data/AppDbContext.cs b/src/Budget.Infrastructure/Data/AppDbContext.cs index 045fb2e..3d6ea0b 100644 --- a/src/Budget.Infrastructure/Data/AppDbContext.cs +++ b/src/Budget.Infrastructure/Data/AppDbContext.cs @@ -22,6 +22,12 @@ public class AppDbContext(DbContextOptions 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(b => @@ -29,6 +35,7 @@ public class AppDbContext(DbContextOptions 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(b => @@ -39,6 +46,7 @@ public class AppDbContext(DbContextOptions 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(b => @@ -55,6 +63,7 @@ public class AppDbContext(DbContextOptions 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); }); } }