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",
Phone = "555-1000",
ContactEmail = "wh@example.com",
Status = "Active"
Status = "Active",
AccountId = 9
}, CancellationToken.None);
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
entity.AccountId.Should().Be(9);
entity.Title.Should().Be("Main WH");
entity.Address1.Should().Be("1 Depot Rd");
entity.City.Should().Be("Austin");

View file

@ -51,7 +51,8 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = l.PhoneNumber,
Contact = (string?)null,
ContactEmail = l.Email,
Status = l.Status
Status = l.Status,
AccountId = l.AccountId
});
var viewModel = new Pagination_DTO
@ -88,7 +89,8 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = location.PhoneNumber,
Contact = (string?)null,
ContactEmail = location.Email,
location.Status
location.Status,
location.AccountId
};
return Ok(result);
@ -175,7 +177,8 @@ namespace Api.SeaHavenIndustries.Controllers
ZipCode = model.ZipCode,
Phone = model.Phone,
ContactEmail = model.ContactEmail,
Status = model.Status
Status = model.Status,
AccountId = model.GetAccountId()
};
}
@ -191,7 +194,8 @@ namespace Api.SeaHavenIndustries.Controllers
ZipCode = model.ZipCode,
Phone = model.Phone,
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
ContactEmail = entity.Email,
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
entity.Email = dto.ContactEmail;
entity.Status = dto.Status;
// AccountId field doesn't exist in database schema - ignored
entity.AccountId = dto.GetAccountId();
return entity;
}

View file

@ -124,6 +124,13 @@ namespace Data.SeaHavenIndustries
builder.Entity<Locations>()
.Property(l => l.ExternalLocationId)
.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>()
.HasIndex(r => new { r.Source, r.Kind, r.ExternalId })
.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"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Address1")
.HasColumnType("nvarchar(max)");
@ -1553,6 +1556,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Locations");
});
@ -3210,6 +3215,16 @@ namespace Data.SeaHavenIndustries.Migrations
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 =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
@ -3845,6 +3860,8 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.Navigation("Account");
b.Navigation("Templates");
b.Navigation("workOrders");

View file

@ -1,12 +1,13 @@
using System;
using System.ComponentModel.DataAnnotations.Schema;
using static System.Runtime.InteropServices.JavaScript.JSType;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class Locations : FullAuditEntity
{
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? Name { 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)
{
// AccountId doesn't exist in database - return all for now
// TODO: Add AccountId column to database if needed
return await _context.Locations.ToListAsync();
return await _context.Locations
.AsNoTracking()
.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(

View file

@ -19,6 +19,13 @@ namespace SeaHaven.DataServices.Interfaces
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 UpdateAsync(Locations location);
Task DeleteAsync(int id);

View file

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

View file

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

View file

@ -94,6 +94,7 @@ namespace SeaHaven.Services.Implementation
City = dto.City,
State = dto.State,
Zip = dto.Zipcode,
AccountId = dto.AccountId,
CreatedDate = DateTime.UtcNow,
createdby = userId
};
@ -121,6 +122,7 @@ namespace SeaHaven.Services.Implementation
if (dto.City != null) location.City = dto.City;
if (dto.State != null) location.State = dto.State;
if (dto.Zipcode != null) location.Zip = dto.Zipcode;
if (dto.AccountId.HasValue) location.AccountId = dto.AccountId;
location.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
@ -187,7 +189,8 @@ namespace SeaHaven.Services.Implementation
Zip = request.ZipCode,
PhoneNumber = request.Phone,
Email = request.ContactEmail,
Status = request.Status
Status = request.Status,
AccountId = request.AccountId
};
await _locationDataService.AddAsync(location, cancellationToken);
@ -208,6 +211,7 @@ namespace SeaHaven.Services.Implementation
location.PhoneNumber = request.Phone;
location.Email = request.ContactEmail;
location.Status = request.Status;
location.AccountId = request.AccountId;
await _locationDataService.UpdateAsync(location, cancellationToken);
}
@ -233,6 +237,7 @@ namespace SeaHaven.Services.Implementation
PhoneNumber = location.PhoneNumber,
Email = location.Email,
Status = location.Status,
AccountId = location.AccountId,
CreatedDate = location.CreatedDate,
CreatedBy = location.createdby
};

View file

@ -9,10 +9,12 @@ namespace SeaHaven.Services.Implementation
public class WorkOrderAccountResolver : IWorkOrderAccountResolver
{
private readonly IAccountDataService _accounts;
private readonly ILocationDataService _locations;
public WorkOrderAccountResolver(IAccountDataService accounts)
public WorkOrderAccountResolver(IAccountDataService accounts, ILocationDataService locations)
{
_accounts = accounts;
_locations = locations;
}
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(
string? customer,
CancellationToken cancellationToken = default)

View file

@ -46,9 +46,9 @@ namespace SeaHaven.Services.Implementation
if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors);
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
var accountId = await _accountResolver.ResolveForBoardCreateAsync(
user,
request.Customer,
request.LocationId,
CancellationToken.None);
var woNumber = await ResolveWoNumberAsync(request.WoNumber);

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// 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>
public interface IWorkOrderAccountResolver
{
@ -17,12 +17,23 @@ namespace SeaHaven.Services.Interfaces
/// <summary>
/// Authenticated create: claim account_id, or org-wide Customer unique match.
/// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved.
/// Used by legacy AddWorkorder — not board create.
/// </summary>
Task<int> ResolveForAuthenticatedCreateAsync(
ClaimsPrincipal user,
string? customer,
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>
/// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved.
/// </summary>

View file

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

View file

@ -1,4 +1,5 @@
using System.Security.Claims;
using FluentValidation;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
@ -22,54 +23,57 @@ public class WorkOrderAccountScopeTests
return new ApplicationDbContext(options);
}
[Fact]
public async Task BoardCreate_WithAccountClaim_StampsAccountId()
private static WorkOrderBoardCreateService CreateBoardCreate(ApplicationDbContext context)
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
var resolver = WorkOrderAccountTestHelpers.Resolver(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(
return new WorkOrderBoardCreateService(
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 row = await create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
SiteCode = "BK5",
LocationId = 11
},
user,
"actor-1");
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal(7, wo.AccountId);
Assert.Equal(11, wo.LocationId);
}
[Fact]
public async Task BoardCreate_MissingScope_ThrowsForbidden()
{
await using var context = CreateContext();
var resolver = WorkOrderAccountTestHelpers.Resolver(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);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 1);
var create = CreateBoardCreate(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
SiteCode = "BK5",
LocationId = 11
},
WorkOrderAccountTestHelpers.MissingScope(),
"actor-1"));
@ -79,49 +83,80 @@ public class WorkOrderAccountScopeTests
}
[Fact]
public async Task BoardCreate_OrgWide_WithUniqueCustomer_StampsAccountId()
public async Task BoardCreate_OrgWide_WithLocationAccount_StampsAccountId()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer");
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 22, accountId: 3, name: "DAL");
var resolver = WorkOrderAccountTestHelpers.Resolver(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 create = CreateBoardCreate(context);
var row = await create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL",
Customer = "Unique Customer"
LocationId = 22,
Customer = "Ignored Client Customer"
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1");
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal(3, wo.AccountId);
Assert.Equal("Unique Customer", wo.Customer);
Assert.Equal(22, wo.LocationId);
Assert.Equal("Ignored Client Customer", wo.Customer);
}
[Fact]
public async Task BoardCreate_OrgWide_UnresolvedCustomer_ThrowsAccountUnresolved()
public async Task BoardCreate_OrgWide_LocationWithoutAccount_ThrowsAccountUnresolved()
{
await using var context = CreateContext();
var resolver = WorkOrderAccountTestHelpers.Resolver(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);
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 33, accountId: null);
var create = CreateBoardCreate(context);
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(
new WorkOrderBoardCreateRequestDto
{
@ -131,10 +166,56 @@ public class WorkOrderAccountScopeTests
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1"));
Assert.Equal("AccountUnresolved", ex.Code);
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 Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
{

View file

@ -11,7 +11,9 @@ namespace SeaHavenIndustries.Tests;
internal static class WorkOrderAccountTestHelpers
{
public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context)
=> new WorkOrderAccountResolver(new AccountDataService(context));
=> new WorkOrderAccountResolver(
new AccountDataService(context),
new LocationDataService(context));
public static ClaimsPrincipal AccountUser(
string userId = "actor-1",
@ -65,4 +67,53 @@ internal static class WorkOrderAccountTestHelpers
});
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 WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
@ -54,6 +55,7 @@ public class WorkOrderBoardCreateRelationalTests
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
Description = "Relational create"
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId);
@ -95,6 +97,7 @@ public class WorkOrderBoardCreateRelationalTests
});
await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
@ -115,6 +118,7 @@ public class WorkOrderBoardCreateRelationalTests
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
Description = "Should roll back",
PocContactId = 999_999
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId));

View file

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

View file

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

View file

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

View file

@ -12,8 +12,10 @@
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
same account filter at service/data entry; authorize before storing blobs
- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId`
from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip
- **Creates** (board): stamp `AccountId` from JWT `account_id` or `Location.AccountId`
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)
## Context (historical)
@ -31,8 +33,13 @@ in review (fail-open) and replaced by the contract below.
AccountId). Not inferred from missing `account_id`.
4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
5. **Create stamp**:
- Authenticated + `account_id` → stamp claim (ignore client AccountId).
- Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else
- Board `POST /workorders/board`: required `locationId`. Scoped → stamp
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`.
- Ingest / webhook / sync → same Customer resolution; unresolved create is
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,
detail, search, list, or media until AccountId is assigned (or they are Admin
with `org_scope=all`).
- Locations do not carry AccountId in the EF model; Customer name match is the
unauthenticated resolution path.
- Board create resolves from `Location.AccountId`. Customer name match remains
the ingest/webhook/legacy resolution path.
## Excepted rule