diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 5f3abe2..e6d97bf 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -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"); diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index b44f58e..dd91983 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -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() }; } } diff --git a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs index dcd3c03..c458bf8 100644 --- a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs @@ -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; } diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index fb8b588..a5256dc 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -124,6 +124,13 @@ namespace Data.SeaHavenIndustries builder.Entity() .Property(l => l.ExternalLocationId) .HasMaxLength(450); + builder.Entity() + .HasOne(l => l.Account) + .WithMany() + .HasForeignKey(l => l.AccountId) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() + .HasIndex(l => l.AccountId); builder.Entity() .HasIndex(r => new { r.Source, r.Kind, r.ExternalId }) .IsUnique() diff --git a/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs b/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs new file mode 100644 index 0000000..06d81bd --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH221_LocationAccountScope : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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; +"); + } + + /// + 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"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index a3051ed..5d86b49 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1486,6 +1486,9 @@ namespace Data.SeaHavenIndustries.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("AccountId") + .HasColumnType("int"); + b.Property("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"); diff --git a/Data.SeaHavenIndustries/Models/Locations.cs b/Data.SeaHavenIndustries/Models/Locations.cs index 9b43e49..f2d9e2e 100644 --- a/Data.SeaHavenIndustries/Models/Locations.cs +++ b/Data.SeaHavenIndustries/Models/Locations.cs @@ -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; } diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 7ccb6b8..48ade48 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -31,9 +31,23 @@ namespace SeaHaven.DataServices.Implementation public async Task> 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 Items, int TotalCount)> GetPagedAsync( diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index a88609a..1bba950 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -19,6 +19,13 @@ namespace SeaHaven.DataServices.Interfaces Task> GetSiteOptionsAsync(string? search = null); + /// + /// Exists is false when the row is missing. AccountId is null when the site has no account. + /// + Task<(bool Exists, int? AccountId)> GetAccountScopeAsync( + int locationId, + CancellationToken cancellationToken = default); + Task AddAsync(Locations location); Task UpdateAsync(Locations location); Task DeleteAsync(int id); diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 3a6edcd..fe694f8 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -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 diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index d9718ff..593415e 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -21,8 +21,8 @@ namespace SeaHaven.Services.DTOs public bool? IsAddOn { get; set; } public string? SiteCode { get; set; } /// - /// 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). /// public string? Customer { get; set; } public string? Description { get; set; } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index da98b66..2a8fbcf 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -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 }; diff --git a/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs b/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs index d6a9419..c09d2f7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs @@ -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 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 ResolveForUnauthenticatedCreateAsync( string? customer, CancellationToken cancellationToken = default) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index f644f6e..c584b75 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -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); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs b/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs index 8fd209c..6aac4d1 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs @@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces { /// /// 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. /// public interface IWorkOrderAccountResolver { @@ -17,12 +17,23 @@ namespace SeaHaven.Services.Interfaces /// /// 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. /// Task ResolveForAuthenticatedCreateAsync( ClaimsPrincipal user, string? customer, CancellationToken cancellationToken = default); + /// + /// 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. + /// + Task ResolveForBoardCreateAsync( + ClaimsPrincipal user, + int? locationId, + CancellationToken cancellationToken = default); + /// /// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved. /// diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 2ed2868..7eb2364 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -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) diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 4b75111..85086b2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -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(() => 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(() => + 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(() => + 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(() => 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(() => + 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() { diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs index 471c0ea..cb7c9f9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs @@ -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(); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index 29be2eb..fe1aee0 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -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)); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 53f4f84..ef58783 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -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 { "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 { "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 { @@ -525,6 +551,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List() }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -540,6 +567,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("", "", null), @@ -565,6 +593,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("Backup Lead", "", null) @@ -584,6 +613,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("", "+1 555-0101", null) @@ -599,6 +629,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("Backup Lead", "+1 555-0101", "After hours"), diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs index b1e60f3..62ff194 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -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(); diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs index 2b90e5d..31ac539 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -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(); diff --git a/docs/adr/0001-work-order-single-org-scope.md b/docs/adr/0001-work-order-single-org-scope.md index 79b1599..84b8832 100644 --- a/docs/adr/0001-work-order-single-org-scope.md +++ b/docs/adr/0001-work-order-single-org-scope.md @@ -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