mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
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:
parent
5857f8483a
commit
61923b2a7d
23 changed files with 447 additions and 77 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue