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:
@@ -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;
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.SaveChangesAsync();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Budget.Core.Models;
|
||||||
|
|
||||||
|
public interface ISoftDeletable
|
||||||
|
{
|
||||||
|
bool IsDeleted { get; set; }
|
||||||
|
DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user