Merge pull request #87 from Sea-Haven-Industries/feat/sh-221-location-owned-account
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

feat(work-orders): stamp board create account from location
This commit is contained in:
Arthur Bassi 2026-08-26 10:34:14 -03:00 • committed by GitHub
commit 5a70d5004e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 829 additions and 107 deletions

View file

@ -46,11 +46,13 @@ public class LocationServiceTests
ZipCode = "73301", ZipCode = "73301",
Phone = "555-1000", Phone = "555-1000",
ContactEmail = "wh@example.com", ContactEmail = "wh@example.com",
Status = "Active" Status = "Active",
AccountId = 9
}, CancellationToken.None); }, CancellationToken.None);
var entity = ctx.Locations.Single(); var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse"); entity.Name.Should().Be("Warehouse");
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");
@ -117,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()
{ {

View file

@ -51,7 +51,8 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = l.PhoneNumber, Phone = l.PhoneNumber,
Contact = (string?)null, Contact = (string?)null,
ContactEmail = l.Email, ContactEmail = l.Email,
Status = l.Status Status = l.Status,
AccountId = l.AccountId
}); });
var viewModel = new Pagination_DTO var viewModel = new Pagination_DTO
@ -88,7 +89,8 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = location.PhoneNumber, Phone = location.PhoneNumber,
Contact = (string?)null, Contact = (string?)null,
ContactEmail = location.Email, ContactEmail = location.Email,
location.Status location.Status,
location.AccountId
}; };
return Ok(result); return Ok(result);

View file

@ -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)

View file

@ -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()
}; };
} }
} }

View file

@ -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()
}; };
} }
@ -49,7 +48,7 @@ namespace Api.SeaHavenIndustries.DTOs
Contact = null, // Not in database schema Contact = null, // Not in database schema
ContactEmail = entity.Email, ContactEmail = entity.Email,
Status = entity.Status, Status = entity.Status,
AccountId = null // Not in database schema AccountId = entity.AccountId?.ToString()
}; };
} }
@ -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;
// AccountId field doesn't exist in database schema - ignored
return entity; return entity;
} }

View file

@ -124,6 +124,13 @@ namespace Data.SeaHavenIndustries
builder.Entity<Locations>() builder.Entity<Locations>()
.Property(l => l.ExternalLocationId) .Property(l => l.ExternalLocationId)
.HasMaxLength(450); .HasMaxLength(450);
builder.Entity<Locations>()
.HasOne(l => l.Account)
.WithMany()
.HasForeignKey(l => l.AccountId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Locations>()
.HasIndex(l => l.AccountId);
builder.Entity<WorkOrderExternalReceipt>() builder.Entity<WorkOrderExternalReceipt>()
.HasIndex(r => new { r.Source, r.Kind, r.ExternalId }) .HasIndex(r => new { r.Source, r.Kind, r.ExternalId })
.IsUnique() .IsUnique()

View file

@ -0,0 +1,72 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260826120000_SH221_LocationAccountScope")]
public partial class SH221_LocationAccountScope : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AccountId",
table: "Locations",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Locations_AccountId",
table: "Locations",
column: "AccountId");
migrationBuilder.AddForeignKey(
name: "FK_Locations_Accounts_AccountId",
table: "Locations",
column: "AccountId",
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.Sql(@"
;WITH UniqueLocationAccounts AS (
SELECT
wo.[LocationId] AS [LocationId],
MIN(wo.[AccountId]) AS [AccountId]
FROM [workOrders] wo
WHERE wo.[LocationId] IS NOT NULL
AND wo.[AccountId] IS NOT NULL
GROUP BY wo.[LocationId]
HAVING COUNT(DISTINCT wo.[AccountId]) = 1
)
UPDATE loc
SET loc.[AccountId] = ula.[AccountId]
FROM [Locations] loc
INNER JOIN UniqueLocationAccounts ula
ON ula.[LocationId] = loc.[Id]
WHERE loc.[AccountId] IS NULL;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Locations_Accounts_AccountId",
table: "Locations");
migrationBuilder.DropIndex(
name: "IX_Locations_AccountId",
table: "Locations");
migrationBuilder.DropColumn(
name: "AccountId",
table: "Locations");
}
}
}

View file

@ -1486,6 +1486,9 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Address1") b.Property<string>("Address1")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -1553,6 +1556,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Locations"); b.ToTable("Locations");
}); });
@ -3210,6 +3215,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
@ -3845,6 +3860,8 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{ {
b.Navigation("Account");
b.Navigation("Templates"); b.Navigation("Templates");
b.Navigation("workOrders"); b.Navigation("workOrders");

View file

@ -1,12 +1,13 @@
using System; using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Data.SeaHavenIndustries namespace Data.SeaHavenIndustries
{ {
public class Locations : FullAuditEntity public class Locations : FullAuditEntity
{ {
public int Id { get; set; } public int Id { get; set; }
public int? AccountId { get; set; }
[ForeignKey(nameof(AccountId))]
public Accounts? Account { get; set; }
public string? Title { get; set; } public string? Title { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
public string? Latitude { get; set; } public string? Latitude { get; set; }

View file

@ -31,9 +31,23 @@ namespace SeaHaven.DataServices.Implementation
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId) public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
{ {
// AccountId doesn't exist in database - return all for now return await _context.Locations
// TODO: Add AccountId column to database if needed .AsNoTracking()
return await _context.Locations.ToListAsync(); .Where(l => l.AccountId == accountId)
.ToListAsync();
}
public async Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
int locationId,
CancellationToken cancellationToken = default)
{
var row = await _context.Locations
.AsNoTracking()
.Where(l => l.Id == locationId)
.Select(l => new { l.AccountId })
.FirstOrDefaultAsync(cancellationToken);
return row == null ? (false, null) : (true, row.AccountId);
} }
public async Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync( public async Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(

View file

@ -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);
} }
} }

View file

@ -19,6 +19,13 @@ namespace SeaHaven.DataServices.Interfaces
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null); Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
/// <summary>
/// Exists is false when the row is missing. AccountId is null when the site has no account.
/// </summary>
Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
int locationId,
CancellationToken cancellationToken = default);
Task<Locations> AddAsync(Locations location); Task<Locations> AddAsync(Locations location);
Task UpdateAsync(Locations location); Task UpdateAsync(Locations location);
Task DeleteAsync(int id); Task DeleteAsync(int id);

View file

@ -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);
} }
} }

View file

@ -13,6 +13,7 @@ namespace SeaHaven.Services.DTOs
public string? PhoneNumber { get; set; } public string? PhoneNumber { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public int? AccountId { get; set; }
public DateTime? CreatedDate { get; set; } public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; } public string? CreatedBy { get; set; }
} }
@ -48,6 +49,7 @@ namespace SeaHaven.Services.DTOs
public string? Phone { get; set; } public string? Phone { get; set; }
public string? ContactEmail { get; set; } public string? ContactEmail { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public int? AccountId { get; set; }
} }
public class LocationUpdateRequestDTO public class LocationUpdateRequestDTO
@ -61,6 +63,7 @@ namespace SeaHaven.Services.DTOs
public string? Phone { get; set; } public string? Phone { get; set; }
public string? ContactEmail { get; set; } public string? ContactEmail { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public int? AccountId { get; set; }
} }
public class SiteOptionDTO public class SiteOptionDTO

View file

@ -21,8 +21,8 @@ namespace SeaHaven.Services.DTOs
public bool? IsAddOn { get; set; } public bool? IsAddOn { get; set; }
public string? SiteCode { get; set; } public string? SiteCode { get; set; }
/// <summary> /// <summary>
/// Optional CRM customer name. Required when the caller is org-wide (no account_id) /// Optional display customer name. Ignored when stamping AccountId
/// so AccountId can be resolved server-side. /// (board create resolves from Location.AccountId).
/// </summary> /// </summary>
public string? Customer { get; set; } public string? Customer { get; set; }
public string? Description { get; set; } public string? Description { get; set; }

View file

@ -233,6 +233,7 @@ namespace SeaHaven.Services.Implementation
PhoneNumber = location.PhoneNumber, PhoneNumber = location.PhoneNumber,
Email = location.Email, Email = location.Email,
Status = location.Status, Status = location.Status,
AccountId = location.AccountId,
CreatedDate = location.CreatedDate, CreatedDate = location.CreatedDate,
CreatedBy = location.createdby CreatedBy = location.createdby
}; };

View file

@ -9,10 +9,12 @@ namespace SeaHaven.Services.Implementation
public class WorkOrderAccountResolver : IWorkOrderAccountResolver public class WorkOrderAccountResolver : IWorkOrderAccountResolver
{ {
private readonly IAccountDataService _accounts; private readonly IAccountDataService _accounts;
private readonly ILocationDataService _locations;
public WorkOrderAccountResolver(IAccountDataService accounts) public WorkOrderAccountResolver(IAccountDataService accounts, ILocationDataService locations)
{ {
_accounts = accounts; _accounts = accounts;
_locations = locations;
} }
public int? ResolveAccountFilter(ClaimsPrincipal user) public int? ResolveAccountFilter(ClaimsPrincipal user)
@ -47,6 +49,55 @@ namespace SeaHaven.Services.Implementation
} }
} }
public async Task<int> ResolveForBoardCreateAsync(
ClaimsPrincipal user,
int? locationId,
CancellationToken cancellationToken = default)
{
if (locationId is not int id || id <= 0)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
"locationId is required.");
}
var (exists, locationAccountId) = await _locations.GetAccountScopeAsync(id, cancellationToken);
if (!exists)
{
throw new WorkOrderBoardValidationException(
"NotFound",
"Location was not found.");
}
if (locationAccountId is not int resolvedAccountId)
{
throw new WorkOrderBoardValidationException(
"AccountUnresolved",
"Work order account could not be resolved from location.");
}
switch (WorkOrderMediaAuthorization.ResolveMediaScope(user))
{
case MediaAccountScope.Account account:
if (account.AccountId != resolvedAccountId)
{
throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to create a work order for this location.");
}
return account.AccountId;
case MediaAccountScope.OrgWide:
return resolvedAccountId;
default:
throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to create work orders without account scope.");
}
}
public Task<int> ResolveForUnauthenticatedCreateAsync( public Task<int> ResolveForUnauthenticatedCreateAsync(
string? customer, string? customer,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)

View file

@ -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.ResolveForAuthenticatedCreateAsync( var accountId = await _accountResolver.ResolveForBoardCreateAsync(
user, user,
request.Customer, 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;
} }

View file

@ -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;

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
{ {
/// <summary> /// <summary>
/// Server-derived work-order account scope (SH-221): claims for authenticated callers, /// Server-derived work-order account scope (SH-221): claims for authenticated callers,
/// unique Accounts.Name ↔ Customer for org-wide / unauthenticated creates. /// Location.AccountId for board create, unique Accounts.Name ↔ Customer for ingest/webhook.
/// </summary> /// </summary>
public interface IWorkOrderAccountResolver public interface IWorkOrderAccountResolver
{ {
@ -17,12 +17,23 @@ namespace SeaHaven.Services.Interfaces
/// <summary> /// <summary>
/// Authenticated create: claim account_id, or org-wide Customer unique match. /// Authenticated create: claim account_id, or org-wide Customer unique match.
/// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved. /// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved.
/// Used by legacy AddWorkorder — not board create.
/// </summary> /// </summary>
Task<int> ResolveForAuthenticatedCreateAsync( Task<int> ResolveForAuthenticatedCreateAsync(
ClaimsPrincipal user, ClaimsPrincipal user,
string? customer, string? customer,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Board create: stamp from JWT account_id or Location.AccountId. Never trusts body customer/accountId.
/// Missing locationId → InvalidValue. Missing location → NotFound. Null Location.AccountId → AccountUnresolved.
/// Scoped location mismatch → Forbidden.
/// </summary>
Task<int> ResolveForBoardCreateAsync(
ClaimsPrincipal user,
int? locationId,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved. /// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved.
/// </summary> /// </summary>

View file

@ -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);
} }
} }

View file

@ -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);
} }
} }
} }

View file

@ -62,8 +62,8 @@ namespace SeaHaven.Services.Validation
.When(x => !string.IsNullOrEmpty(x.VendorNotes)); .When(x => !string.IsNullOrEmpty(x.VendorNotes));
RuleFor(x => x.LocationId) RuleFor(x => x.LocationId)
.GreaterThan(0) .NotNull().WithMessage("locationId is required.")
.When(x => x.LocationId.HasValue); .GreaterThan(0).WithMessage("locationId is required.");
RuleFor(x => x.VendorId) RuleFor(x => x.VendorId)
.GreaterThan(0) .GreaterThan(0)

View file

@ -0,0 +1,189 @@
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 CreateDatabaseAsync(dbName);
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 CreateDatabaseAsync(string dbName)
{
var quoted = QuoteSqlServerIdentifier(dbName);
await using var connection = new SqlConnection(LocalDbMaster);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE {quoted};";
await command.ExecuteNonQueryAsync();
}
private static async Task DropDatabaseAsync(string dbName)
{
try
{
var quoted = QuoteSqlServerIdentifier(dbName);
await using var connection = new SqlConnection(LocalDbMaster);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText =
$"""
IF DB_ID(@dbName) IS NOT NULL
BEGIN
ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE {quoted};
END
""";
command.Parameters.AddWithValue("@dbName", dbName);
await command.ExecuteNonQueryAsync();
}
catch
{
// Best-effort cleanup for ephemeral LocalDB databases.
}
}
private static string QuoteSqlServerIdentifier(string name) =>
"[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
}

View file

@ -1,8 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using FluentValidation;
using Data.SeaHavenIndustries; 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;
@ -22,54 +24,57 @@ public class WorkOrderAccountScopeTests
return new ApplicationDbContext(options); return new ApplicationDbContext(options);
} }
[Fact] private static WorkOrderBoardCreateService CreateBoardCreate(ApplicationDbContext context)
public async Task BoardCreate_WithAccountClaim_StampsAccountId()
{ {
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
var resolver = WorkOrderAccountTestHelpers.Resolver(context); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, resolver); var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService( return new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver); boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
}
[Fact]
public async Task BoardCreate_WithAccountClaim_StampsAccountId()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
var create = CreateBoardCreate(context);
var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"); var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher");
var row = await create.CreateAsync( var row = await create.CreateAsync(
new WorkOrderBoardCreateRequestDto new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 11
}, },
user, user,
"actor-1"); "actor-1");
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal(7, wo.AccountId); Assert.Equal(7, wo.AccountId);
Assert.Equal(11, wo.LocationId);
} }
[Fact] [Fact]
public async Task BoardCreate_MissingScope_ThrowsForbidden() public async Task BoardCreate_MissingScope_ThrowsForbidden()
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var resolver = WorkOrderAccountTestHelpers.Resolver(context); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
var boardData = new WorkOrderBoardDataService(context); await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 1);
var mutationData = new WorkOrderBoardMutationDataService(context); var create = CreateBoardCreate(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);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
create.CreateAsync( create.CreateAsync(
new WorkOrderBoardCreateRequestDto new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 11
}, },
WorkOrderAccountTestHelpers.MissingScope(), WorkOrderAccountTestHelpers.MissingScope(),
"actor-1")); "actor-1"));
@ -79,49 +84,80 @@ public class WorkOrderAccountScopeTests
} }
[Fact] [Fact]
public async Task BoardCreate_OrgWide_WithUniqueCustomer_StampsAccountId() public async Task BoardCreate_OrgWide_WithLocationAccount_StampsAccountId()
{ {
await using var context = CreateContext(); await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer"); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer");
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 22, accountId: 3, name: "DAL");
var resolver = WorkOrderAccountTestHelpers.Resolver(context); var create = CreateBoardCreate(context);
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);
var row = await create.CreateAsync( var row = await create.CreateAsync(
new WorkOrderBoardCreateRequestDto new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PO, WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL", SiteCode = "DAL",
Customer = "Unique Customer" LocationId = 22,
Customer = "Ignored Client Customer"
}, },
WorkOrderAccountTestHelpers.OrgWideAdmin(), WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1"); "admin-1");
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal(3, wo.AccountId); Assert.Equal(3, wo.AccountId);
Assert.Equal("Unique Customer", wo.Customer); Assert.Equal(22, wo.LocationId);
Assert.Equal("Ignored Client Customer", wo.Customer);
} }
[Fact] [Fact]
public async Task BoardCreate_OrgWide_UnresolvedCustomer_ThrowsAccountUnresolved() public async Task BoardCreate_OrgWide_LocationWithoutAccount_ThrowsAccountUnresolved()
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var resolver = WorkOrderAccountTestHelpers.Resolver(context); await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 33, accountId: null);
var boardData = new WorkOrderBoardDataService(context); var create = CreateBoardCreate(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);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 33
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1"));
Assert.Equal("AccountUnresolved", ex.Code);
Assert.Empty(context.workOrders);
}
[Fact]
public async Task BoardCreate_OrgWide_MissingLocation_ThrowsNotFound()
{
await using var context = CreateContext();
var create = CreateBoardCreate(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 404
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1"));
Assert.Equal("NotFound", ex.Code);
Assert.Empty(context.workOrders);
}
[Fact]
public async Task BoardCreate_MissingLocationId_ThrowsValidation()
{
await using var context = CreateContext();
var create = CreateBoardCreate(context);
await Assert.ThrowsAsync<ValidationException>(() =>
create.CreateAsync( create.CreateAsync(
new WorkOrderBoardCreateRequestDto new WorkOrderBoardCreateRequestDto
{ {
@ -131,10 +167,88 @@ public class WorkOrderAccountScopeTests
WorkOrderAccountTestHelpers.OrgWideAdmin(), WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1")); "admin-1"));
Assert.Equal("AccountUnresolved", ex.Code);
Assert.Empty(context.workOrders); Assert.Empty(context.workOrders);
} }
[Fact]
public async Task BoardCreate_Scoped_LocationOfOtherAccount_ThrowsForbidden()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 50, accountId: 2);
var create = CreateBoardCreate(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 50
},
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"),
"disp-1"));
Assert.Equal("Forbidden", ex.Code);
Assert.Empty(context.workOrders);
}
[Fact]
public async Task BoardCreate_OrgWide_ServiceOmitted_Succeeds()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Org");
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 60, accountId: 3);
var create = CreateBoardCreate(context);
var row = await create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 60
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1");
Assert.Null(row.Pm);
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
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()
{ {
@ -682,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);
}
} }

View file

@ -11,7 +11,9 @@ namespace SeaHavenIndustries.Tests;
internal static class WorkOrderAccountTestHelpers internal static class WorkOrderAccountTestHelpers
{ {
public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context) public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context)
=> new WorkOrderAccountResolver(new AccountDataService(context)); => new WorkOrderAccountResolver(
new AccountDataService(context),
new LocationDataService(context));
public static ClaimsPrincipal AccountUser( public static ClaimsPrincipal AccountUser(
string userId = "actor-1", string userId = "actor-1",
@ -65,4 +67,53 @@ internal static class WorkOrderAccountTestHelpers
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
public static async Task EnsureLocationAsync(
ApplicationDbContext context,
int id = 1,
int? accountId = 1,
string name = "BK5")
{
if (await context.Locations.AnyAsync(l => l.Id == id))
return;
context.Locations.Add(new Locations
{
Id = id,
Name = name,
AccountId = accountId
});
await context.SaveChangesAsync();
}
public static void SeedBoardCreateScope(
ApplicationDbContext context,
int accountId = 1,
int locationId = 1,
string accountName = "Acme Corp")
{
if (!context.Accounts.Local.Any(a => a.Id == accountId)
&& !context.Accounts.Any(a => a.Id == accountId))
{
context.Accounts.Add(new Accounts
{
Id = accountId,
Name = accountName,
IsDeleted = false
});
}
if (!context.Locations.Local.Any(l => l.Id == locationId)
&& !context.Locations.Any(l => l.Id == locationId))
{
context.Locations.Add(new Locations
{
Id = locationId,
Name = "BK5",
AccountId = accountId
});
}
context.SaveChanges();
}
} }

View file

@ -35,6 +35,7 @@ public class WorkOrderBoardCreateRelationalTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
@ -54,6 +55,7 @@ public class WorkOrderBoardCreateRelationalTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
Description = "Relational create" Description = "Relational create"
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId); }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId);
@ -95,6 +97,7 @@ public class WorkOrderBoardCreateRelationalTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
@ -115,6 +118,7 @@ public class WorkOrderBoardCreateRelationalTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
Description = "Should roll back", Description = "Should roll back",
PocContactId = 999_999 PocContactId = 999_999
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId)); }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId));

View file

@ -19,6 +19,7 @@ public class WorkOrderBoardCreateServiceTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
var context = new ApplicationDbContext(options); var context = new ApplicationDbContext(options);
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
@ -38,7 +39,8 @@ public class WorkOrderBoardCreateServiceTests
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
@ -61,7 +63,8 @@ public class WorkOrderBoardCreateServiceTests
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("SH00002", result.WoNumber); Assert.Equal("SH00002", result.WoNumber);
@ -77,7 +80,8 @@ public class WorkOrderBoardCreateServiceTests
{ {
WoNumber = "12345", WoNumber = "12345",
WorkOrderType = WorkOrderType.PO, WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL" SiteCode = "DAL",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("00000012345", result.WoNumber); Assert.Equal("00000012345", result.WoNumber);
@ -95,7 +99,8 @@ public class WorkOrderBoardCreateServiceTests
{ {
WoNumber = "99999", WoNumber = "99999",
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("DuplicateWoNumber", ex.Code); Assert.Equal("DuplicateWoNumber", ex.Code);
@ -110,6 +115,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
AssignTo = "dispatcher-1", AssignTo = "dispatcher-1",
ScheduledDate = new DateTime(2026, 6, 25) ScheduledDate = new DateTime(2026, 6, 25)
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -127,6 +133,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
ScheduleWeekOnly = true, ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22) TargetWeek = new DateOnly(2026, 6, 22)
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -145,6 +152,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
ScheduleWeekOnly = true ScheduleWeekOnly = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
} }
@ -160,6 +168,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
VendorId = 5, VendorId = 5,
ApptDate = new DateTime(2026, 6, 26) ApptDate = new DateTime(2026, 6, 26)
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -177,6 +186,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
Description = "Test WO" Description = "Test WO"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -195,7 +205,8 @@ public class WorkOrderBoardCreateServiceTests
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var logs = await context.WorkOrderAuditLogs.ToListAsync(); var logs = await context.WorkOrderAuditLogs.ToListAsync();
@ -214,6 +225,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
PrimaryService = "HVAC PM", PrimaryService = "HVAC PM",
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" }, ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
ServiceNotes = "Unit on roof" ServiceNotes = "Unit on roof"
@ -241,6 +253,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.Reactive, WorkOrderType = WorkOrderType.Reactive,
SiteCode = "CHI", SiteCode = "CHI",
LocationId = 1,
Trade = "Legacy Trade", Trade = "Legacy Trade",
PrimaryService = "Plumbing" PrimaryService = "Plumbing"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -258,6 +271,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
ExtraServices = new List<string> { "Filter change" } ExtraServices = new List<string> { "Filter change" }
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
} }
@ -271,6 +285,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.Reactive, WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
PocName = "Jane Site Lead", PocName = "Jane Site Lead",
PocPhone = "+1 555-0100", PocPhone = "+1 555-0100",
PocNotes = "Call 30 min before" PocNotes = "Call 30 min before"
@ -294,6 +309,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
TechPhone = "+1 555-0199", TechPhone = "+1 555-0199",
VendorNotes = "Gate code 4421" VendorNotes = "Gate code 4421"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -317,6 +333,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
VendorId = 55, VendorId = 55,
ApptDate = new DateTime(2026, 7, 18), ApptDate = new DateTime(2026, 7, 18),
TechPhone = "+1 555-0199", TechPhone = "+1 555-0199",
@ -343,6 +360,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
Trade = "HVAC", Trade = "HVAC",
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate" Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -368,6 +386,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
VendorId = 999 VendorId = 999
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
@ -384,6 +403,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
ScheduledDate = new DateTime(2026, 6, 24), ScheduledDate = new DateTime(2026, 6, 24),
IsAddOn = false IsAddOn = false
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -405,6 +425,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.Reactive, WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
ScheduledDate = new DateTime(2099, 3, 10), ScheduledDate = new DateTime(2099, 3, 10),
IsAddOn = true IsAddOn = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -424,6 +445,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
IsAddOn = true IsAddOn = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -441,6 +463,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
AvetaRequired = true AvetaRequired = true
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -460,7 +483,8 @@ public class WorkOrderBoardCreateServiceTests
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.False(result.AvetaRequired); Assert.False(result.AvetaRequired);
@ -480,7 +504,8 @@ public class WorkOrderBoardCreateServiceTests
service.CreateAsync(new WorkOrderBoardCreateRequestDto service.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.AddOn, WorkOrderType = WorkOrderType.AddOn,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType"); Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
@ -495,6 +520,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.Reactive, WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
PocName = "Primary Lead", PocName = "Primary Lead",
AdditionalContacts = new List<WorkOrderAdditionalContactDto> AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{ {
@ -525,6 +551,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto>() AdditionalContacts = new List<WorkOrderAdditionalContactDto>()
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
@ -540,6 +567,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.Reactive, WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto> AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{ {
new("", "", null), new("", "", null),
@ -565,6 +593,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto> AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{ {
new("Backup Lead", "", null) new("Backup Lead", "", null)
@ -584,6 +613,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto> AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{ {
new("", "+1 555-0101", null) new("", "+1 555-0101", null)
@ -599,6 +629,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.Reactive, WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5", SiteCode = "BK5",
LocationId = 1,
AdditionalContacts = new List<WorkOrderAdditionalContactDto> AdditionalContacts = new List<WorkOrderAdditionalContactDto>
{ {
new("Backup Lead", "+1 555-0101", "After hours"), new("Backup Lead", "+1 555-0101", "After hours"),

View file

@ -18,6 +18,7 @@ public class WorkOrderBoardCreateSyncLockTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
@ -37,7 +38,8 @@ public class WorkOrderBoardCreateSyncLockTests
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();
@ -65,6 +67,7 @@ public class WorkOrderBoardCreateSyncLockTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
@ -84,7 +87,8 @@ public class WorkOrderBoardCreateSyncLockTests
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();

View file

@ -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);
}
} }

View file

@ -65,6 +65,7 @@ public class WorkOrderPhase7CoexistenceTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
@ -80,7 +81,8 @@ public class WorkOrderPhase7CoexistenceTests
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();

View file

@ -12,8 +12,10 @@
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter `ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`): - **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
same account filter at service/data entry; authorize before storing blobs same account filter at service/data entry; authorize before storing blobs
- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId` - **Creates** (board): stamp `AccountId` from JWT `account_id` or `Location.AccountId`
from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip via required `locationId`; body `customer`/`accountId` are not trusted. AddWorkorder,
ingest, webhook/recon, sync still stamp from claim or unique `Accounts.Name` ↔
`Customer`; unresolvable → reject/skip
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate) - Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
## Context (historical) ## Context (historical)
@ -31,8 +33,13 @@ in review (fail-open) and replaced by the contract below.
AccountId). Not inferred from missing `account_id`. AccountId). Not inferred from missing `account_id`.
4. **Fail-closed** = no valid account or org-scope claim → Forbidden. 4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
5. **Create stamp**: 5. **Create stamp**:
- Authenticated + `account_id` → stamp claim (ignore client AccountId). - Board `POST /workorders/board`: required `locationId`. Scoped → stamp
- Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else JWT `account_id` only when `Location.AccountId` matches (else Forbidden).
Org-wide → stamp `Location.AccountId`. Missing location → NotFound;
null `Location.AccountId` → AccountUnresolved. Body `customer`/`accountId`
are ignored for the stamp.
- Legacy AddWorkorder: authenticated + `account_id` → stamp claim;
authenticated + `org_scope=all` → unique Customer→Accounts.Name; else
`AccountUnresolved`. `AccountUnresolved`.
- Ingest / webhook / sync → same Customer resolution; unresolved create is - Ingest / webhook / sync → same Customer resolution; unresolved create is
rejected or skipped (no null AccountId on new rows). rejected or skipped (no null AccountId on new rows).
@ -51,8 +58,8 @@ in review (fail-open) and replaced by the contract below.
- Dispatcher/Manager/Supervisor/User without AccountId cannot access board, - Dispatcher/Manager/Supervisor/User without AccountId cannot access board,
detail, search, list, or media until AccountId is assigned (or they are Admin detail, search, list, or media until AccountId is assigned (or they are Admin
with `org_scope=all`). with `org_scope=all`).
- Locations do not carry AccountId in the EF model; Customer name match is the - Board create resolves from `Location.AccountId`. Customer name match remains
unauthenticated resolution path. the ingest/webhook/legacy resolution path.
## Excepted rule ## Excepted rule