feat(work-orders): stamp board create account from location

Org-wide create no longer depends on customer name. POST /workorders/board requires locationId and stamps WorkOrder.AccountId from Location.AccountId.
This commit is contained in:
Arthur Bassi 2026-08-26 09:12:48 -03:00
parent 5857f8483a
commit 61923b2a7d
23 changed files with 447 additions and 77 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().Be(9);
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");

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);
@ -175,7 +177,8 @@ 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()
}; };
} }
@ -191,7 +194,8 @@ 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()
}; };
} }
} }

View file

@ -49,7 +49,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 +68,7 @@ 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 entity.AccountId = dto.GetAccountId();
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,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
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

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

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

@ -94,6 +94,7 @@ 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
}; };
@ -121,6 +122,7 @@ 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))
@ -187,7 +189,8 @@ 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);
@ -208,6 +211,7 @@ 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);
} }
@ -233,6 +237,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

@ -46,9 +46,9 @@ namespace SeaHaven.Services.Implementation
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.None);
var woNumber = await ResolveWoNumberAsync(request.WoNumber); var woNumber = await ResolveWoNumberAsync(request.WoNumber);

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

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

@ -1,4 +1,5 @@
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;
@ -22,54 +23,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 +83,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 +166,56 @@ 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] [Fact]
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows() public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
{ {

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

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