diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 77bdb27..d707f27 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -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."); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index 2873689..0e85674 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -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() + .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(() => + 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 options)