fix(work-orders): wrap board create in a transaction for atomic persistence

This commit is contained in:
Arthur Bassi 2026-07-10 11:38:58 -03:00
parent 9dc17d4255
commit ceeb0c8fa3
2 changed files with 89 additions and 18 deletions

View file

@ -122,29 +122,47 @@ namespace SeaHaven.Services.Implementation
}
}
_context.workOrders.Add(workOrder);
await _context.SaveChangesAsync();
// Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and
// audit/lock/contact rows. A transaction keeps the create atomic across both saves.
await using var transaction = _context.Database.IsRelational()
? await _context.Database.BeginTransactionAsync()
: null;
if (dispatch != null && workOrder.PrimaryDispatchId == null)
try
{
workOrder.PrimaryDispatchId = dispatch.Id;
dispatch.WorkOrderId = workOrder.Id;
}
_context.workOrders.Add(workOrder);
await _context.SaveChangesAsync();
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
StageChanges(workOrder.Id, changes, actorId, dispatch);
if (request.PocContactId.HasValue)
{
_context.WorkOrderContacts.Add(new WorkOrderContacts
if (dispatch != null && workOrder.PrimaryDispatchId == null)
{
WorkorderId = workOrder.Id,
ContactId = request.PocContactId,
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
});
}
workOrder.PrimaryDispatchId = dispatch.Id;
dispatch.WorkOrderId = workOrder.Id;
}
await _context.SaveChangesAsync();
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
StageChanges(workOrder.Id, changes, actorId, dispatch);
if (request.PocContactId.HasValue)
{
_context.WorkOrderContacts.Add(new WorkOrderContacts
{
WorkorderId = workOrder.Id,
ContactId = request.PocContactId,
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
});
}
await _context.SaveChangesAsync();
if (transaction is not null)
await transaction.CommitAsync();
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync();
throw;
}
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");

View file

@ -67,6 +67,59 @@ public class WorkOrderBoardCreateRelationalTests
Assert.DoesNotContain(locks, l => l.WorkOrderId == 0);
}
[Fact]
public async Task Create_WhenContactPersistenceFails_RollsBackWorkOrder()
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new SqliteBoardTestDbContext(options);
await context.Database.EnsureCreatedAsync();
const string actorId = "actor-1";
context.Users.Add(new ApplicationUser
{
Id = actorId,
UserName = "actor-1",
NormalizedUserName = "ACTOR-1",
Email = "actor@test.local",
NormalizedEmail = "ACTOR@TEST.LOCAL"
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var service = new WorkOrderBoardCreateService(
context,
boardData,
boardService,
audit,
new WorkOrderBoardCreateValidation());
await Assert.ThrowsAsync<DbUpdateException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
Description = "Should roll back",
PocContactId = 999_999
}, actorId));
// Fresh context on the same connection so we observe committed state only.
await using var verify = new SqliteBoardTestDbContext(options);
Assert.Empty(await verify.workOrders.AsNoTracking().ToListAsync());
Assert.Empty(await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync());
Assert.Empty(await verify.WorkOrderFieldLocks.AsNoTracking().ToListAsync());
Assert.Empty(await verify.WorkOrderContacts.AsNoTracking().ToListAsync());
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
}
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
{
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)