mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 09:13:30 +00:00
fix(work-orders): keep location account server-owned and forward create cancellation
Stop client writes from changing Locations.AccountId, make the SH-221 migration discoverable, and thread the board-create CancellationToken through lookup and persistence.
This commit is contained in:
parent
61923b2a7d
commit
2e56ec7678
16 changed files with 379 additions and 44 deletions
|
|
@ -52,7 +52,7 @@ public class LocationServiceTests
|
||||||
|
|
||||||
var entity = ctx.Locations.Single();
|
var entity = ctx.Locations.Single();
|
||||||
entity.Name.Should().Be("Warehouse");
|
entity.Name.Should().Be("Warehouse");
|
||||||
entity.AccountId.Should().Be(9);
|
entity.AccountId.Should().BeNull();
|
||||||
entity.Title.Should().Be("Main WH");
|
entity.Title.Should().Be("Main WH");
|
||||||
entity.Address1.Should().Be("1 Depot Rd");
|
entity.Address1.Should().Be("1 Depot Rd");
|
||||||
entity.City.Should().Be("Austin");
|
entity.City.Should().Be("Austin");
|
||||||
|
|
@ -119,6 +119,71 @@ public class LocationServiceTests
|
||||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLocationFromRequestAsync_PreservesAccountIdWhenOmitted()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Old", "Round Rock");
|
||||||
|
existing.AccountId = 4;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Name = "New",
|
||||||
|
City = "Plano"
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLocationFromRequestAsync_IgnoresClientAccountIdRelabel()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Owned", "Austin");
|
||||||
|
existing.AccountId = 4;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Name = "Owned",
|
||||||
|
AccountId = 99
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLocationAsync_IgnoresClientAccountId()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var created = await NewService(ctx).CreateLocationAsync(new CreateLocationDTO
|
||||||
|
{
|
||||||
|
LocationName = "Site",
|
||||||
|
AccountId = 9
|
||||||
|
}, "42");
|
||||||
|
|
||||||
|
created.AccountId.Should().BeNull();
|
||||||
|
ctx.Locations.Single().AccountId.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLocationAsync_IgnoresClientAccountIdRelabel()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Owned", "Austin");
|
||||||
|
existing.AccountId = 4;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateLocationAsync(existing.Id, new UpdateLocationDTO
|
||||||
|
{
|
||||||
|
LocationName = "Owned",
|
||||||
|
AccountId = 99
|
||||||
|
}, "42");
|
||||||
|
|
||||||
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -177,8 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
ZipCode = model.ZipCode,
|
ZipCode = model.ZipCode,
|
||||||
Phone = model.Phone,
|
Phone = model.Phone,
|
||||||
ContactEmail = model.ContactEmail,
|
ContactEmail = model.ContactEmail,
|
||||||
Status = model.Status,
|
Status = model.Status
|
||||||
AccountId = model.GetAccountId()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,8 +193,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
ZipCode = model.ZipCode,
|
ZipCode = model.ZipCode,
|
||||||
Phone = model.Phone,
|
Phone = model.Phone,
|
||||||
ContactEmail = model.ContactEmail,
|
ContactEmail = model.ContactEmail,
|
||||||
Status = model.Status,
|
Status = model.Status
|
||||||
AccountId = model.GetAccountId()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,12 +166,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("board")]
|
[HttpPost("board")]
|
||||||
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
|
public async Task<IActionResult> CreateBoardWorkOrder(
|
||||||
|
[FromBody] WorkOrderBoardCreateRequestDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var row = await _boardCreateService.CreateAsync(request, User, actorId);
|
var row = await _boardCreateService.CreateAsync(request, User, actorId, cancellationToken);
|
||||||
return Ok(row);
|
return Ok(row);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
Address = this.Address,
|
Address = this.Address,
|
||||||
City = this.City,
|
City = this.City,
|
||||||
State = this.State,
|
State = this.State,
|
||||||
Zipcode = this.ZipCode,
|
Zipcode = this.ZipCode
|
||||||
AccountId = this.GetAccountId()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
Address = this.Address,
|
Address = this.Address,
|
||||||
City = this.City,
|
City = this.City,
|
||||||
State = this.State,
|
State = this.State,
|
||||||
Zipcode = this.ZipCode,
|
Zipcode = this.ZipCode
|
||||||
AccountId = this.GetAccountId()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +67,6 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
// Contact field doesn't exist in database schema - ignored
|
// Contact field doesn't exist in database schema - ignored
|
||||||
entity.Email = dto.ContactEmail;
|
entity.Email = dto.ContactEmail;
|
||||||
entity.Status = dto.Status;
|
entity.Status = dto.Status;
|
||||||
entity.AccountId = dto.GetAccountId();
|
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
namespace Data.SeaHavenIndustries.Migrations
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260826120000_SH221_LocationAccountScope")]
|
||||||
public partial class SH221_LocationAccountScope : Migration
|
public partial class SH221_LocationAccountScope : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return rows.FirstOrDefault();
|
return rows.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
|
public async Task<bool> InternalWoNumberExistsAsync(
|
||||||
|
string normalizedWoNumber,
|
||||||
|
int excludeWorkOrderId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.workOrders
|
return await _context.workOrders
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|
@ -92,7 +95,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
w.Id != excludeWorkOrderId
|
w.Id != excludeWorkOrderId
|
||||||
&& w.istemplate != true
|
&& w.istemplate != true
|
||||||
&& (w.IsDeleted != true || w.IsDeleted == null)
|
&& (w.IsDeleted != true || w.IsDeleted == null)
|
||||||
&& w.InternalWONumber == normalizedWoNumber);
|
&& w.InternalWONumber == normalizedWoNumber, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
int? accountId = null,
|
int? accountId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
|
Task<bool> InternalWoNumberExistsAsync(
|
||||||
|
string normalizedWoNumber,
|
||||||
|
int excludeWorkOrderId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ namespace SeaHaven.Services.Implementation
|
||||||
City = dto.City,
|
City = dto.City,
|
||||||
State = dto.State,
|
State = dto.State,
|
||||||
Zip = dto.Zipcode,
|
Zip = dto.Zipcode,
|
||||||
AccountId = dto.AccountId,
|
|
||||||
CreatedDate = DateTime.UtcNow,
|
CreatedDate = DateTime.UtcNow,
|
||||||
createdby = userId
|
createdby = userId
|
||||||
};
|
};
|
||||||
|
|
@ -122,7 +121,6 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (dto.City != null) location.City = dto.City;
|
if (dto.City != null) location.City = dto.City;
|
||||||
if (dto.State != null) location.State = dto.State;
|
if (dto.State != null) location.State = dto.State;
|
||||||
if (dto.Zipcode != null) location.Zip = dto.Zipcode;
|
if (dto.Zipcode != null) location.Zip = dto.Zipcode;
|
||||||
if (dto.AccountId.HasValue) location.AccountId = dto.AccountId;
|
|
||||||
|
|
||||||
location.LastModificationTime = DateTime.UtcNow;
|
location.LastModificationTime = DateTime.UtcNow;
|
||||||
if (int.TryParse(userId, out int userIdInt))
|
if (int.TryParse(userId, out int userIdInt))
|
||||||
|
|
@ -189,8 +187,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = request.ZipCode,
|
Zip = request.ZipCode,
|
||||||
PhoneNumber = request.Phone,
|
PhoneNumber = request.Phone,
|
||||||
Email = request.ContactEmail,
|
Email = request.ContactEmail,
|
||||||
Status = request.Status,
|
Status = request.Status
|
||||||
AccountId = request.AccountId
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await _locationDataService.AddAsync(location, cancellationToken);
|
await _locationDataService.AddAsync(location, cancellationToken);
|
||||||
|
|
@ -211,7 +208,6 @@ namespace SeaHaven.Services.Implementation
|
||||||
location.PhoneNumber = request.Phone;
|
location.PhoneNumber = request.Phone;
|
||||||
location.Email = request.ContactEmail;
|
location.Email = request.ContactEmail;
|
||||||
location.Status = request.Status;
|
location.Status = request.Status;
|
||||||
location.AccountId = request.AccountId;
|
|
||||||
|
|
||||||
await _locationDataService.UpdateAsync(location, cancellationToken);
|
await _locationDataService.UpdateAsync(location, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,18 +40,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
||||||
WorkOrderBoardCreateRequestDto request,
|
WorkOrderBoardCreateRequestDto request,
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string? actorId)
|
string? actorId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var validationResult = await _validator.ValidateAsync(request);
|
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validationResult.IsValid)
|
if (!validationResult.IsValid)
|
||||||
throw new ValidationException(validationResult.Errors);
|
throw new ValidationException(validationResult.Errors);
|
||||||
|
|
||||||
var accountId = await _accountResolver.ResolveForBoardCreateAsync(
|
var accountId = await _accountResolver.ResolveForBoardCreateAsync(
|
||||||
user,
|
user,
|
||||||
request.LocationId,
|
request.LocationId,
|
||||||
CancellationToken.None);
|
cancellationToken);
|
||||||
|
|
||||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
||||||
var siteCode = request.SiteCode!.Trim();
|
var siteCode = request.SiteCode!.Trim();
|
||||||
var primaryService = ResolvePrimaryService(request);
|
var primaryService = ResolvePrimaryService(request);
|
||||||
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||||
|
|
@ -69,7 +70,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
||||||
{
|
{
|
||||||
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, CancellationToken.None))
|
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, cancellationToken))
|
||||||
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,7 +224,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
await _mutationData.SaveAsync(ct);
|
await _mutationData.SaveAsync(ct);
|
||||||
}, CancellationToken.None);
|
}, cancellationToken);
|
||||||
|
|
||||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
|
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
|
||||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||||
|
|
@ -274,20 +275,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
private static string? TrimOrNull(string? value)
|
private static string? TrimOrNull(string? value)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
private async Task<string> ResolveWoNumberAsync(string? requested)
|
private async Task<string> ResolveWoNumberAsync(string? requested, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(requested))
|
if (!string.IsNullOrWhiteSpace(requested))
|
||||||
{
|
{
|
||||||
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
|
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
|
||||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||||
|
|
||||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0))
|
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0, cancellationToken))
|
||||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None);
|
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(cancellationToken);
|
||||||
for (var attempt = 0; attempt < 20; attempt++)
|
for (var attempt = 0; attempt < 20; attempt++)
|
||||||
{
|
{
|
||||||
string candidate;
|
string candidate;
|
||||||
|
|
@ -300,7 +301,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
|
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0, cancellationToken))
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
||||||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
|
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
|
||||||
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
||||||
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
|
@ -170,11 +170,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrder workOrder,
|
WorkOrder workOrder,
|
||||||
Dispatch? dispatch,
|
Dispatch? dispatch,
|
||||||
string? value,
|
string? value,
|
||||||
string auditField)
|
string auditField,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return field switch
|
return field switch
|
||||||
{
|
{
|
||||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
||||||
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||||
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
||||||
|
|
@ -278,12 +279,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new WorkOrderBoardConcurrencyException(currentState);
|
throw new WorkOrderBoardConcurrencyException(currentState);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
|
private async Task<FieldChange> ApplyWoNumber(
|
||||||
|
WorkOrder workOrder,
|
||||||
|
string? value,
|
||||||
|
string auditField,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
||||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||||
|
|
||||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id))
|
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
|
||||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||||
|
|
||||||
var old = workOrder.InternalWONumber;
|
var old = workOrder.InternalWONumber;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<WorkOrderBoardRowDto> CreateAsync(
|
Task<WorkOrderBoardRowDto> CreateAsync(
|
||||||
WorkOrderBoardCreateRequestDto request,
|
WorkOrderBoardCreateRequestDto request,
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string? actorId);
|
string? actorId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,6 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||||
|
|
||||||
RuleFor(x => x.AccountId)
|
|
||||||
.GreaterThan(0).WithMessage("Valid account must be selected")
|
|
||||||
.When(x => x.AccountId.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,10 +60,6 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||||
|
|
||||||
RuleFor(x => x.AccountId)
|
|
||||||
.GreaterThan(0).WithMessage("Valid account must be selected")
|
|
||||||
.When(x => x.AccountId.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Migrations;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class SH221LocationAccountScopeSqlServerTests
|
||||||
|
{
|
||||||
|
private const string LocalDbMaster =
|
||||||
|
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SH221_LocationAccountScope_ApplyOnSqlServer_WhenLocalDbAvailable()
|
||||||
|
{
|
||||||
|
if (!await IsLocalDbAvailableAsync())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dbName = $"SH221LocationAccount_{Guid.NewGuid():N}";
|
||||||
|
var connectionString =
|
||||||
|
$@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(connectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
await using (var createTables = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
createTables.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE Accounts (
|
||||||
|
Id int NOT NULL IDENTITY PRIMARY KEY
|
||||||
|
);
|
||||||
|
CREATE TABLE Locations (
|
||||||
|
Id int NOT NULL IDENTITY PRIMARY KEY
|
||||||
|
);
|
||||||
|
CREATE TABLE workOrders (
|
||||||
|
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||||
|
LocationId int NULL,
|
||||||
|
AccountId int NULL
|
||||||
|
);
|
||||||
|
SET IDENTITY_INSERT Accounts ON;
|
||||||
|
INSERT INTO Accounts (Id) VALUES (1);
|
||||||
|
SET IDENTITY_INSERT Accounts OFF;
|
||||||
|
SET IDENTITY_INSERT Locations ON;
|
||||||
|
INSERT INTO Locations (Id) VALUES (11);
|
||||||
|
SET IDENTITY_INSERT Locations OFF;
|
||||||
|
INSERT INTO workOrders (LocationId, AccountId) VALUES (11, 1);
|
||||||
|
""";
|
||||||
|
await createTables.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlServer(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
await ApplyMigrationUpAsync(context, new SH221_LocationAccountScope());
|
||||||
|
|
||||||
|
await using (var assertCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
assertCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT c.name
|
||||||
|
FROM sys.columns c
|
||||||
|
WHERE c.object_id = OBJECT_ID(N'Locations')
|
||||||
|
AND c.name = N'AccountId';
|
||||||
|
""";
|
||||||
|
var column = await assertCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal("AccountId", column);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var indexCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
indexCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT name
|
||||||
|
FROM sys.indexes
|
||||||
|
WHERE object_id = OBJECT_ID(N'Locations')
|
||||||
|
AND name = N'IX_Locations_AccountId';
|
||||||
|
""";
|
||||||
|
var index = await indexCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal("IX_Locations_AccountId", index);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var fkCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
fkCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT name
|
||||||
|
FROM sys.foreign_keys
|
||||||
|
WHERE parent_object_id = OBJECT_ID(N'Locations')
|
||||||
|
AND name = N'FK_Locations_Accounts_AccountId';
|
||||||
|
""";
|
||||||
|
var fk = await fkCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal("FK_Locations_Accounts_AccountId", fk);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var backfillCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
backfillCmd.CommandText = "SELECT AccountId FROM Locations WHERE Id = 11;";
|
||||||
|
var stamped = await backfillCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal(1, Convert.ToInt32(stamped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DropDatabaseAsync(dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
|
||||||
|
{
|
||||||
|
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||||
|
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||||
|
?? throw new InvalidOperationException("Migration.Up not found.");
|
||||||
|
up.Invoke(migration, [builder]);
|
||||||
|
|
||||||
|
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||||
|
var commands = sqlGenerator.Generate(builder.Operations, model: null);
|
||||||
|
foreach (var command in commands)
|
||||||
|
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> IsLocalDbAvailableAsync()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(LocalDbMaster);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (SqlException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DropDatabaseAsync(string dbName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(LocalDbMaster);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
$"""
|
||||||
|
IF DB_ID(N'{dbName}') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||||
|
DROP DATABASE [{dbName}];
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup for ephemeral LocalDB databases.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
@ -216,6 +217,38 @@ public class WorkOrderAccountScopeTests
|
||||||
Assert.Equal(3, wo.AccountId);
|
Assert.Equal(3, wo.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_ForwardsCancellationToken_ToLocationAccountLookup()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
|
||||||
|
|
||||||
|
var locations = new RecordingLocationDataService(new LocationDataService(context));
|
||||||
|
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), locations);
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
|
var create = new WorkOrderBoardCreateService(
|
||||||
|
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await create.CreateAsync(
|
||||||
|
new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 11
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"),
|
||||||
|
"actor-1",
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
|
Assert.Equal(cts.Token, locations.LastScopeToken);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
||||||
{
|
{
|
||||||
|
|
@ -763,4 +796,54 @@ public class WorkOrderAccountScopeTests
|
||||||
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
|
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
|
||||||
Assert.Equal("Forbidden", missingEx.Code);
|
Assert.Equal("Forbidden", missingEx.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingLocationDataService : ILocationDataService
|
||||||
|
{
|
||||||
|
private readonly ILocationDataService _inner;
|
||||||
|
|
||||||
|
public RecordingLocationDataService(ILocationDataService inner)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CancellationToken LastScopeToken { get; private set; }
|
||||||
|
|
||||||
|
public Task<Locations?> GetByIdAsync(int id) => _inner.GetByIdAsync(id);
|
||||||
|
public Task<Locations?> GetByIdWithDetailsAsync(int id) => _inner.GetByIdWithDetailsAsync(id);
|
||||||
|
public Task<IEnumerable<Locations>> GetAllAsync() => _inner.GetAllAsync();
|
||||||
|
public Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId) => _inner.GetByAccountIdAsync(accountId);
|
||||||
|
public Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null)
|
||||||
|
=> _inner.GetPagedAsync(page, pageSize, search);
|
||||||
|
public Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null)
|
||||||
|
=> _inner.GetAddressbookPagedAsync(page, pageSize, search);
|
||||||
|
public Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
|
||||||
|
=> _inner.GetSiteOptionsAsync(search);
|
||||||
|
|
||||||
|
public Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
|
||||||
|
int locationId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
LastScopeToken = cancellationToken;
|
||||||
|
return _inner.GetAccountScopeAsync(locationId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Locations> AddAsync(Locations location) => _inner.AddAsync(location);
|
||||||
|
public Task UpdateAsync(Locations location) => _inner.UpdateAsync(location);
|
||||||
|
public Task DeleteAsync(int id) => _inner.DeleteAsync(id);
|
||||||
|
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
||||||
|
public Task<int> CountAsync() => _inner.CountAsync();
|
||||||
|
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||||
|
int page, int pageSize, string? search, CancellationToken cancellationToken)
|
||||||
|
=> _inner.GetListPagedAsync(page, pageSize, search, cancellationToken);
|
||||||
|
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
||||||
|
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
=> _inner.GetByIdForUpdateAsync(id, cancellationToken);
|
||||||
|
public Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
|
||||||
|
=> _inner.AddAsync(location, cancellationToken);
|
||||||
|
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)
|
||||||
|
=> _inner.UpdateAsync(location, cancellationToken);
|
||||||
|
public Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
=> _inner.DeleteByIdAsync(id, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,17 @@ public class WorkOrderMigrationDiscoveryTests
|
||||||
|
|
||||||
Assert.Contains("20260824150000_SH117_AvetaRequired", migrations);
|
Assert.Contains("20260824150000_SH117_AvetaRequired", migrations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SH221_LocationAccountScope_is_discoverable_by_ef_runtime()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite("DataSource=:memory:")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
using var context = new ApplicationDbContext(options);
|
||||||
|
var migrations = context.Database.GetMigrations().ToList();
|
||||||
|
|
||||||
|
Assert.Contains("20260826120000_SH221_LocationAccountScope", migrations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue