mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 17:12:09 +00:00
Merge pull request #87 from Sea-Haven-Industries/feat/sh-221-location-owned-account
feat(work-orders): stamp board create account from location
This commit is contained in:
commit
5a70d5004e
32 changed files with 829 additions and 107 deletions
|
|
@ -46,11 +46,13 @@ public class LocationServiceTests
|
||||||
ZipCode = "73301",
|
ZipCode = "73301",
|
||||||
Phone = "555-1000",
|
Phone = "555-1000",
|
||||||
ContactEmail = "wh@example.com",
|
ContactEmail = "wh@example.com",
|
||||||
Status = "Active"
|
Status = "Active",
|
||||||
|
AccountId = 9
|
||||||
}, CancellationToken.None);
|
}, CancellationToken.None);
|
||||||
|
|
||||||
var entity = ctx.Locations.Single();
|
var entity = ctx.Locations.Single();
|
||||||
entity.Name.Should().Be("Warehouse");
|
entity.Name.Should().Be("Warehouse");
|
||||||
|
entity.AccountId.Should().BeNull();
|
||||||
entity.Title.Should().Be("Main WH");
|
entity.Title.Should().Be("Main WH");
|
||||||
entity.Address1.Should().Be("1 Depot Rd");
|
entity.Address1.Should().Be("1 Depot Rd");
|
||||||
entity.City.Should().Be("Austin");
|
entity.City.Should().Be("Austin");
|
||||||
|
|
@ -117,6 +119,71 @@ public class LocationServiceTests
|
||||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLocationFromRequestAsync_PreservesAccountIdWhenOmitted()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Old", "Round Rock");
|
||||||
|
existing.AccountId = 4;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Name = "New",
|
||||||
|
City = "Plano"
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLocationFromRequestAsync_IgnoresClientAccountIdRelabel()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Owned", "Austin");
|
||||||
|
existing.AccountId = 4;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Name = "Owned",
|
||||||
|
AccountId = 99
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLocationAsync_IgnoresClientAccountId()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var created = await NewService(ctx).CreateLocationAsync(new CreateLocationDTO
|
||||||
|
{
|
||||||
|
LocationName = "Site",
|
||||||
|
AccountId = 9
|
||||||
|
}, "42");
|
||||||
|
|
||||||
|
created.AccountId.Should().BeNull();
|
||||||
|
ctx.Locations.Single().AccountId.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLocationAsync_IgnoresClientAccountIdRelabel()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Owned", "Austin");
|
||||||
|
existing.AccountId = 4;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateLocationAsync(existing.Id, new UpdateLocationDTO
|
||||||
|
{
|
||||||
|
LocationName = "Owned",
|
||||||
|
AccountId = 99
|
||||||
|
}, "42");
|
||||||
|
|
||||||
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
Phone = l.PhoneNumber,
|
Phone = l.PhoneNumber,
|
||||||
Contact = (string?)null,
|
Contact = (string?)null,
|
||||||
ContactEmail = l.Email,
|
ContactEmail = l.Email,
|
||||||
Status = l.Status
|
Status = l.Status,
|
||||||
|
AccountId = l.AccountId
|
||||||
});
|
});
|
||||||
|
|
||||||
var viewModel = new Pagination_DTO
|
var viewModel = new Pagination_DTO
|
||||||
|
|
@ -88,7 +89,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
Phone = location.PhoneNumber,
|
Phone = location.PhoneNumber,
|
||||||
Contact = (string?)null,
|
Contact = (string?)null,
|
||||||
ContactEmail = location.Email,
|
ContactEmail = location.Email,
|
||||||
location.Status
|
location.Status,
|
||||||
|
location.AccountId
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
|
|
|
||||||
|
|
@ -166,12 +166,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("board")]
|
[HttpPost("board")]
|
||||||
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
|
public async Task<IActionResult> CreateBoardWorkOrder(
|
||||||
|
[FromBody] WorkOrderBoardCreateRequestDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var row = await _boardCreateService.CreateAsync(request, User, actorId);
|
var row = await _boardCreateService.CreateAsync(request, User, actorId, cancellationToken);
|
||||||
return Ok(row);
|
return Ok(row);
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
Address = this.Address,
|
Address = this.Address,
|
||||||
City = this.City,
|
City = this.City,
|
||||||
State = this.State,
|
State = this.State,
|
||||||
Zipcode = this.ZipCode,
|
Zipcode = this.ZipCode
|
||||||
AccountId = this.GetAccountId()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
Address = this.Address,
|
Address = this.Address,
|
||||||
City = this.City,
|
City = this.City,
|
||||||
State = this.State,
|
State = this.State,
|
||||||
Zipcode = this.ZipCode,
|
Zipcode = this.ZipCode
|
||||||
AccountId = this.GetAccountId()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +48,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
Contact = null, // Not in database schema
|
Contact = null, // Not in database schema
|
||||||
ContactEmail = entity.Email,
|
ContactEmail = entity.Email,
|
||||||
Status = entity.Status,
|
Status = entity.Status,
|
||||||
AccountId = null // Not in database schema
|
AccountId = entity.AccountId?.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +67,6 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
// Contact field doesn't exist in database schema - ignored
|
// Contact field doesn't exist in database schema - ignored
|
||||||
entity.Email = dto.ContactEmail;
|
entity.Email = dto.ContactEmail;
|
||||||
entity.Status = dto.Status;
|
entity.Status = dto.Status;
|
||||||
// AccountId field doesn't exist in database schema - ignored
|
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,13 @@ namespace Data.SeaHavenIndustries
|
||||||
builder.Entity<Locations>()
|
builder.Entity<Locations>()
|
||||||
.Property(l => l.ExternalLocationId)
|
.Property(l => l.ExternalLocationId)
|
||||||
.HasMaxLength(450);
|
.HasMaxLength(450);
|
||||||
|
builder.Entity<Locations>()
|
||||||
|
.HasOne(l => l.Account)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(l => l.AccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.Entity<Locations>()
|
||||||
|
.HasIndex(l => l.AccountId);
|
||||||
builder.Entity<WorkOrderExternalReceipt>()
|
builder.Entity<WorkOrderExternalReceipt>()
|
||||||
.HasIndex(r => new { r.Source, r.Kind, r.ExternalId })
|
.HasIndex(r => new { r.Source, r.Kind, r.ExternalId })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260826120000_SH221_LocationAccountScope")]
|
||||||
|
public partial class SH221_LocationAccountScope : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AccountId",
|
||||||
|
table: "Locations",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Locations_AccountId",
|
||||||
|
table: "Locations",
|
||||||
|
column: "AccountId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Locations_Accounts_AccountId",
|
||||||
|
table: "Locations",
|
||||||
|
column: "AccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
;WITH UniqueLocationAccounts AS (
|
||||||
|
SELECT
|
||||||
|
wo.[LocationId] AS [LocationId],
|
||||||
|
MIN(wo.[AccountId]) AS [AccountId]
|
||||||
|
FROM [workOrders] wo
|
||||||
|
WHERE wo.[LocationId] IS NOT NULL
|
||||||
|
AND wo.[AccountId] IS NOT NULL
|
||||||
|
GROUP BY wo.[LocationId]
|
||||||
|
HAVING COUNT(DISTINCT wo.[AccountId]) = 1
|
||||||
|
)
|
||||||
|
UPDATE loc
|
||||||
|
SET loc.[AccountId] = ula.[AccountId]
|
||||||
|
FROM [Locations] loc
|
||||||
|
INNER JOIN UniqueLocationAccounts ula
|
||||||
|
ON ula.[LocationId] = loc.[Id]
|
||||||
|
WHERE loc.[AccountId] IS NULL;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Locations_Accounts_AccountId",
|
||||||
|
table: "Locations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Locations_AccountId",
|
||||||
|
table: "Locations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AccountId",
|
||||||
|
table: "Locations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1486,6 +1486,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("AccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("Address1")
|
b.Property<string>("Address1")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -1553,6 +1556,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId");
|
||||||
|
|
||||||
b.ToTable("Locations");
|
b.ToTable("Locations");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3210,6 +3215,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
|
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
|
||||||
|
|
@ -3845,6 +3860,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Account");
|
||||||
|
|
||||||
b.Navigation("Templates");
|
b.Navigation("Templates");
|
||||||
|
|
||||||
b.Navigation("workOrders");
|
b.Navigation("workOrders");
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
using System;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
|
||||||
|
|
||||||
namespace Data.SeaHavenIndustries
|
namespace Data.SeaHavenIndustries
|
||||||
{
|
{
|
||||||
public class Locations : FullAuditEntity
|
public class Locations : FullAuditEntity
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
|
[ForeignKey(nameof(AccountId))]
|
||||||
|
public Accounts? Account { get; set; }
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? Latitude { get; set; }
|
public string? Latitude { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,23 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
|
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
|
||||||
{
|
{
|
||||||
// AccountId doesn't exist in database - return all for now
|
return await _context.Locations
|
||||||
// TODO: Add AccountId column to database if needed
|
.AsNoTracking()
|
||||||
return await _context.Locations.ToListAsync();
|
.Where(l => l.AccountId == accountId)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
|
||||||
|
int locationId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var row = await _context.Locations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(l => l.Id == locationId)
|
||||||
|
.Select(l => new { l.AccountId })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return row == null ? (false, null) : (true, row.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return rows.FirstOrDefault();
|
return rows.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
|
public async Task<bool> InternalWoNumberExistsAsync(
|
||||||
|
string normalizedWoNumber,
|
||||||
|
int excludeWorkOrderId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.workOrders
|
return await _context.workOrders
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|
@ -92,7 +95,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
w.Id != excludeWorkOrderId
|
w.Id != excludeWorkOrderId
|
||||||
&& w.istemplate != true
|
&& w.istemplate != true
|
||||||
&& (w.IsDeleted != true || w.IsDeleted == null)
|
&& (w.IsDeleted != true || w.IsDeleted == null)
|
||||||
&& w.InternalWONumber == normalizedWoNumber);
|
&& w.InternalWONumber == normalizedWoNumber, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
|
|
||||||
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
|
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exists is false when the row is missing. AccountId is null when the site has no account.
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
|
||||||
|
int locationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<Locations> AddAsync(Locations location);
|
Task<Locations> AddAsync(Locations location);
|
||||||
Task UpdateAsync(Locations location);
|
Task UpdateAsync(Locations location);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
int? accountId = null,
|
int? accountId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
|
Task<bool> InternalWoNumberExistsAsync(
|
||||||
|
string normalizedWoNumber,
|
||||||
|
int excludeWorkOrderId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? PhoneNumber { get; set; }
|
public string? PhoneNumber { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
public DateTime? CreatedDate { get; set; }
|
public DateTime? CreatedDate { get; set; }
|
||||||
public string? CreatedBy { get; set; }
|
public string? CreatedBy { get; set; }
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +49,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? ContactEmail { get; set; }
|
public string? ContactEmail { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LocationUpdateRequestDTO
|
public class LocationUpdateRequestDTO
|
||||||
|
|
@ -61,6 +63,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
public string? ContactEmail { get; set; }
|
public string? ContactEmail { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SiteOptionDTO
|
public class SiteOptionDTO
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public bool? IsAddOn { get; set; }
|
public bool? IsAddOn { get; set; }
|
||||||
public string? SiteCode { get; set; }
|
public string? SiteCode { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)
|
/// Optional display customer name. Ignored when stamping AccountId
|
||||||
/// so AccountId can be resolved server-side.
|
/// (board create resolves from Location.AccountId).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Customer { get; set; }
|
public string? Customer { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
PhoneNumber = location.PhoneNumber,
|
PhoneNumber = location.PhoneNumber,
|
||||||
Email = location.Email,
|
Email = location.Email,
|
||||||
Status = location.Status,
|
Status = location.Status,
|
||||||
|
AccountId = location.AccountId,
|
||||||
CreatedDate = location.CreatedDate,
|
CreatedDate = location.CreatedDate,
|
||||||
CreatedBy = location.createdby
|
CreatedBy = location.createdby
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
public class WorkOrderAccountResolver : IWorkOrderAccountResolver
|
public class WorkOrderAccountResolver : IWorkOrderAccountResolver
|
||||||
{
|
{
|
||||||
private readonly IAccountDataService _accounts;
|
private readonly IAccountDataService _accounts;
|
||||||
|
private readonly ILocationDataService _locations;
|
||||||
|
|
||||||
public WorkOrderAccountResolver(IAccountDataService accounts)
|
public WorkOrderAccountResolver(IAccountDataService accounts, ILocationDataService locations)
|
||||||
{
|
{
|
||||||
_accounts = accounts;
|
_accounts = accounts;
|
||||||
|
_locations = locations;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int? ResolveAccountFilter(ClaimsPrincipal user)
|
public int? ResolveAccountFilter(ClaimsPrincipal user)
|
||||||
|
|
@ -47,6 +49,55 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> ResolveForBoardCreateAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
int? locationId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (locationId is not int id || id <= 0)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"InvalidValue",
|
||||||
|
"locationId is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (exists, locationAccountId) = await _locations.GetAccountScopeAsync(id, cancellationToken);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"NotFound",
|
||||||
|
"Location was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationAccountId is not int resolvedAccountId)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"AccountUnresolved",
|
||||||
|
"Work order account could not be resolved from location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (WorkOrderMediaAuthorization.ResolveMediaScope(user))
|
||||||
|
{
|
||||||
|
case MediaAccountScope.Account account:
|
||||||
|
if (account.AccountId != resolvedAccountId)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"Forbidden",
|
||||||
|
"You are not allowed to create a work order for this location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return account.AccountId;
|
||||||
|
|
||||||
|
case MediaAccountScope.OrgWide:
|
||||||
|
return resolvedAccountId;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"Forbidden",
|
||||||
|
"You are not allowed to create work orders without account scope.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task<int> ResolveForUnauthenticatedCreateAsync(
|
public Task<int> ResolveForUnauthenticatedCreateAsync(
|
||||||
string? customer,
|
string? customer,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|
|
||||||
|
|
@ -40,18 +40,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
||||||
WorkOrderBoardCreateRequestDto request,
|
WorkOrderBoardCreateRequestDto request,
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string? actorId)
|
string? actorId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var validationResult = await _validator.ValidateAsync(request);
|
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validationResult.IsValid)
|
if (!validationResult.IsValid)
|
||||||
throw new ValidationException(validationResult.Errors);
|
throw new ValidationException(validationResult.Errors);
|
||||||
|
|
||||||
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
|
var accountId = await _accountResolver.ResolveForBoardCreateAsync(
|
||||||
user,
|
user,
|
||||||
request.Customer,
|
request.LocationId,
|
||||||
CancellationToken.None);
|
cancellationToken);
|
||||||
|
|
||||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
||||||
var siteCode = request.SiteCode!.Trim();
|
var siteCode = request.SiteCode!.Trim();
|
||||||
var primaryService = ResolvePrimaryService(request);
|
var primaryService = ResolvePrimaryService(request);
|
||||||
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||||
|
|
@ -69,7 +70,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
||||||
{
|
{
|
||||||
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, CancellationToken.None))
|
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, cancellationToken))
|
||||||
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,7 +224,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
await _mutationData.SaveAsync(ct);
|
await _mutationData.SaveAsync(ct);
|
||||||
}, CancellationToken.None);
|
}, cancellationToken);
|
||||||
|
|
||||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
|
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
|
||||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||||
|
|
@ -274,20 +275,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
private static string? TrimOrNull(string? value)
|
private static string? TrimOrNull(string? value)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
private async Task<string> ResolveWoNumberAsync(string? requested)
|
private async Task<string> ResolveWoNumberAsync(string? requested, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(requested))
|
if (!string.IsNullOrWhiteSpace(requested))
|
||||||
{
|
{
|
||||||
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
|
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
|
||||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||||
|
|
||||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0))
|
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0, cancellationToken))
|
||||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None);
|
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(cancellationToken);
|
||||||
for (var attempt = 0; attempt < 20; attempt++)
|
for (var attempt = 0; attempt < 20; attempt++)
|
||||||
{
|
{
|
||||||
string candidate;
|
string candidate;
|
||||||
|
|
@ -300,7 +301,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
|
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0, cancellationToken))
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
||||||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
|
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
|
||||||
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
||||||
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
|
@ -170,11 +170,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrder workOrder,
|
WorkOrder workOrder,
|
||||||
Dispatch? dispatch,
|
Dispatch? dispatch,
|
||||||
string? value,
|
string? value,
|
||||||
string auditField)
|
string auditField,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return field switch
|
return field switch
|
||||||
{
|
{
|
||||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
||||||
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||||
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
||||||
|
|
@ -278,12 +279,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new WorkOrderBoardConcurrencyException(currentState);
|
throw new WorkOrderBoardConcurrencyException(currentState);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
|
private async Task<FieldChange> ApplyWoNumber(
|
||||||
|
WorkOrder workOrder,
|
||||||
|
string? value,
|
||||||
|
string auditField,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
||||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||||
|
|
||||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id))
|
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
|
||||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||||
|
|
||||||
var old = workOrder.InternalWONumber;
|
var old = workOrder.InternalWONumber;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-derived work-order account scope (SH-221): claims for authenticated callers,
|
/// Server-derived work-order account scope (SH-221): claims for authenticated callers,
|
||||||
/// unique Accounts.Name ↔ Customer for org-wide / unauthenticated creates.
|
/// Location.AccountId for board create, unique Accounts.Name ↔ Customer for ingest/webhook.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IWorkOrderAccountResolver
|
public interface IWorkOrderAccountResolver
|
||||||
{
|
{
|
||||||
|
|
@ -17,12 +17,23 @@ namespace SeaHaven.Services.Interfaces
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authenticated create: claim account_id, or org-wide Customer unique match.
|
/// Authenticated create: claim account_id, or org-wide Customer unique match.
|
||||||
/// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved.
|
/// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved.
|
||||||
|
/// Used by legacy AddWorkorder — not board create.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<int> ResolveForAuthenticatedCreateAsync(
|
Task<int> ResolveForAuthenticatedCreateAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string? customer,
|
string? customer,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Board create: stamp from JWT account_id or Location.AccountId. Never trusts body customer/accountId.
|
||||||
|
/// Missing locationId → InvalidValue. Missing location → NotFound. Null Location.AccountId → AccountUnresolved.
|
||||||
|
/// Scoped location mismatch → Forbidden.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> ResolveForBoardCreateAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
int? locationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved.
|
/// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<WorkOrderBoardRowDto> CreateAsync(
|
Task<WorkOrderBoardRowDto> CreateAsync(
|
||||||
WorkOrderBoardCreateRequestDto request,
|
WorkOrderBoardCreateRequestDto request,
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string? actorId);
|
string? actorId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,6 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||||
|
|
||||||
RuleFor(x => x.AccountId)
|
|
||||||
.GreaterThan(0).WithMessage("Valid account must be selected")
|
|
||||||
.When(x => x.AccountId.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,10 +60,6 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||||
|
|
||||||
RuleFor(x => x.AccountId)
|
|
||||||
.GreaterThan(0).WithMessage("Valid account must be selected")
|
|
||||||
.When(x => x.AccountId.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@ namespace SeaHaven.Services.Validation
|
||||||
.When(x => !string.IsNullOrEmpty(x.VendorNotes));
|
.When(x => !string.IsNullOrEmpty(x.VendorNotes));
|
||||||
|
|
||||||
RuleFor(x => x.LocationId)
|
RuleFor(x => x.LocationId)
|
||||||
.GreaterThan(0)
|
.NotNull().WithMessage("locationId is required.")
|
||||||
.When(x => x.LocationId.HasValue);
|
.GreaterThan(0).WithMessage("locationId is required.");
|
||||||
|
|
||||||
RuleFor(x => x.VendorId)
|
RuleFor(x => x.VendorId)
|
||||||
.GreaterThan(0)
|
.GreaterThan(0)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Migrations;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class SH221LocationAccountScopeSqlServerTests
|
||||||
|
{
|
||||||
|
private const string LocalDbMaster =
|
||||||
|
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SH221_LocationAccountScope_ApplyOnSqlServer_WhenLocalDbAvailable()
|
||||||
|
{
|
||||||
|
if (!await IsLocalDbAvailableAsync())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dbName = $"SH221LocationAccount_{Guid.NewGuid():N}";
|
||||||
|
var connectionString =
|
||||||
|
$@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CreateDatabaseAsync(dbName);
|
||||||
|
|
||||||
|
await using var connection = new SqlConnection(connectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
await using (var createTables = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
createTables.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE Accounts (
|
||||||
|
Id int NOT NULL IDENTITY PRIMARY KEY
|
||||||
|
);
|
||||||
|
CREATE TABLE Locations (
|
||||||
|
Id int NOT NULL IDENTITY PRIMARY KEY
|
||||||
|
);
|
||||||
|
CREATE TABLE workOrders (
|
||||||
|
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||||
|
LocationId int NULL,
|
||||||
|
AccountId int NULL
|
||||||
|
);
|
||||||
|
SET IDENTITY_INSERT Accounts ON;
|
||||||
|
INSERT INTO Accounts (Id) VALUES (1);
|
||||||
|
SET IDENTITY_INSERT Accounts OFF;
|
||||||
|
SET IDENTITY_INSERT Locations ON;
|
||||||
|
INSERT INTO Locations (Id) VALUES (11);
|
||||||
|
SET IDENTITY_INSERT Locations OFF;
|
||||||
|
INSERT INTO workOrders (LocationId, AccountId) VALUES (11, 1);
|
||||||
|
""";
|
||||||
|
await createTables.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlServer(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
await ApplyMigrationUpAsync(context, new SH221_LocationAccountScope());
|
||||||
|
|
||||||
|
await using (var assertCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
assertCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT c.name
|
||||||
|
FROM sys.columns c
|
||||||
|
WHERE c.object_id = OBJECT_ID(N'Locations')
|
||||||
|
AND c.name = N'AccountId';
|
||||||
|
""";
|
||||||
|
var column = await assertCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal("AccountId", column);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var indexCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
indexCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT name
|
||||||
|
FROM sys.indexes
|
||||||
|
WHERE object_id = OBJECT_ID(N'Locations')
|
||||||
|
AND name = N'IX_Locations_AccountId';
|
||||||
|
""";
|
||||||
|
var index = await indexCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal("IX_Locations_AccountId", index);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var fkCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
fkCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT name
|
||||||
|
FROM sys.foreign_keys
|
||||||
|
WHERE parent_object_id = OBJECT_ID(N'Locations')
|
||||||
|
AND name = N'FK_Locations_Accounts_AccountId';
|
||||||
|
""";
|
||||||
|
var fk = await fkCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal("FK_Locations_Accounts_AccountId", fk);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var backfillCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
backfillCmd.CommandText = "SELECT AccountId FROM Locations WHERE Id = 11;";
|
||||||
|
var stamped = await backfillCmd.ExecuteScalarAsync();
|
||||||
|
Assert.Equal(1, Convert.ToInt32(stamped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DropDatabaseAsync(dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
|
||||||
|
{
|
||||||
|
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||||
|
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||||
|
?? throw new InvalidOperationException("Migration.Up not found.");
|
||||||
|
up.Invoke(migration, [builder]);
|
||||||
|
|
||||||
|
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||||
|
var commands = sqlGenerator.Generate(builder.Operations, model: null);
|
||||||
|
foreach (var command in commands)
|
||||||
|
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> IsLocalDbAvailableAsync()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(LocalDbMaster);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (SqlException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CreateDatabaseAsync(string dbName)
|
||||||
|
{
|
||||||
|
var quoted = QuoteSqlServerIdentifier(dbName);
|
||||||
|
await using var connection = new SqlConnection(LocalDbMaster);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = $"CREATE DATABASE {quoted};";
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DropDatabaseAsync(string dbName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var quoted = QuoteSqlServerIdentifier(dbName);
|
||||||
|
await using var connection = new SqlConnection(LocalDbMaster);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
$"""
|
||||||
|
IF DB_ID(@dbName) IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||||
|
DROP DATABASE {quoted};
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("@dbName", dbName);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup for ephemeral LocalDB databases.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteSqlServerIdentifier(string name) =>
|
||||||
|
"[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using FluentValidation;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
@ -22,54 +24,57 @@ public class WorkOrderAccountScopeTests
|
||||||
return new ApplicationDbContext(options);
|
return new ApplicationDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
private static WorkOrderBoardCreateService CreateBoardCreate(ApplicationDbContext context)
|
||||||
public async Task BoardCreate_WithAccountClaim_StampsAccountId()
|
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
|
||||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
|
|
||||||
|
|
||||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
var create = new WorkOrderBoardCreateService(
|
return new WorkOrderBoardCreateService(
|
||||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_WithAccountClaim_StampsAccountId()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
|
||||||
|
|
||||||
|
var create = CreateBoardCreate(context);
|
||||||
var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher");
|
var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher");
|
||||||
var row = await create.CreateAsync(
|
var row = await create.CreateAsync(
|
||||||
new WorkOrderBoardCreateRequestDto
|
new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 11
|
||||||
},
|
},
|
||||||
user,
|
user,
|
||||||
"actor-1");
|
"actor-1");
|
||||||
|
|
||||||
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||||
Assert.Equal(7, wo.AccountId);
|
Assert.Equal(7, wo.AccountId);
|
||||||
|
Assert.Equal(11, wo.LocationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BoardCreate_MissingScope_ThrowsForbidden()
|
public async Task BoardCreate_MissingScope_ThrowsForbidden()
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 1);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var create = CreateBoardCreate(context);
|
||||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
||||||
var create = new WorkOrderBoardCreateService(
|
|
||||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
create.CreateAsync(
|
create.CreateAsync(
|
||||||
new WorkOrderBoardCreateRequestDto
|
new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 11
|
||||||
},
|
},
|
||||||
WorkOrderAccountTestHelpers.MissingScope(),
|
WorkOrderAccountTestHelpers.MissingScope(),
|
||||||
"actor-1"));
|
"actor-1"));
|
||||||
|
|
@ -79,49 +84,80 @@ public class WorkOrderAccountScopeTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BoardCreate_OrgWide_WithUniqueCustomer_StampsAccountId()
|
public async Task BoardCreate_OrgWide_WithLocationAccount_StampsAccountId()
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer");
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer");
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 22, accountId: 3, name: "DAL");
|
||||||
|
|
||||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
var create = CreateBoardCreate(context);
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
|
||||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
||||||
var create = new WorkOrderBoardCreateService(
|
|
||||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
|
||||||
|
|
||||||
var row = await create.CreateAsync(
|
var row = await create.CreateAsync(
|
||||||
new WorkOrderBoardCreateRequestDto
|
new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PO,
|
WorkOrderType = WorkOrderType.PO,
|
||||||
SiteCode = "DAL",
|
SiteCode = "DAL",
|
||||||
Customer = "Unique Customer"
|
LocationId = 22,
|
||||||
|
Customer = "Ignored Client Customer"
|
||||||
},
|
},
|
||||||
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
"admin-1");
|
"admin-1");
|
||||||
|
|
||||||
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||||
Assert.Equal(3, wo.AccountId);
|
Assert.Equal(3, wo.AccountId);
|
||||||
Assert.Equal("Unique Customer", wo.Customer);
|
Assert.Equal(22, wo.LocationId);
|
||||||
|
Assert.Equal("Ignored Client Customer", wo.Customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BoardCreate_OrgWide_UnresolvedCustomer_ThrowsAccountUnresolved()
|
public async Task BoardCreate_OrgWide_LocationWithoutAccount_ThrowsAccountUnresolved()
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 33, accountId: null);
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var create = CreateBoardCreate(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
|
||||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
|
||||||
var create = new WorkOrderBoardCreateService(
|
|
||||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
create.CreateAsync(
|
||||||
|
new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 33
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
"admin-1"));
|
||||||
|
|
||||||
|
Assert.Equal("AccountUnresolved", ex.Code);
|
||||||
|
Assert.Empty(context.workOrders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_OrgWide_MissingLocation_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var create = CreateBoardCreate(context);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
create.CreateAsync(
|
||||||
|
new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 404
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
"admin-1"));
|
||||||
|
|
||||||
|
Assert.Equal("NotFound", ex.Code);
|
||||||
|
Assert.Empty(context.workOrders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_MissingLocationId_ThrowsValidation()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var create = CreateBoardCreate(context);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() =>
|
||||||
create.CreateAsync(
|
create.CreateAsync(
|
||||||
new WorkOrderBoardCreateRequestDto
|
new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
|
|
@ -131,10 +167,88 @@ public class WorkOrderAccountScopeTests
|
||||||
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
"admin-1"));
|
"admin-1"));
|
||||||
|
|
||||||
Assert.Equal("AccountUnresolved", ex.Code);
|
|
||||||
Assert.Empty(context.workOrders);
|
Assert.Empty(context.workOrders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_Scoped_LocationOfOtherAccount_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 50, accountId: 2);
|
||||||
|
var create = CreateBoardCreate(context);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
create.CreateAsync(
|
||||||
|
new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 50
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"),
|
||||||
|
"disp-1"));
|
||||||
|
|
||||||
|
Assert.Equal("Forbidden", ex.Code);
|
||||||
|
Assert.Empty(context.workOrders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_OrgWide_ServiceOmitted_Succeeds()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Org");
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 60, accountId: 3);
|
||||||
|
var create = CreateBoardCreate(context);
|
||||||
|
|
||||||
|
var row = await create.CreateAsync(
|
||||||
|
new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 60
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
"admin-1");
|
||||||
|
|
||||||
|
Assert.Null(row.Pm);
|
||||||
|
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||||
|
Assert.Equal(3, wo.AccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardCreate_ForwardsCancellationToken_ToLocationAccountLookup()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
|
||||||
|
|
||||||
|
var locations = new RecordingLocationDataService(new LocationDataService(context));
|
||||||
|
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), locations);
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
|
var create = new WorkOrderBoardCreateService(
|
||||||
|
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await create.CreateAsync(
|
||||||
|
new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
LocationId = 11
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"),
|
||||||
|
"actor-1",
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
|
Assert.Equal(cts.Token, locations.LastScopeToken);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
||||||
{
|
{
|
||||||
|
|
@ -682,4 +796,54 @@ public class WorkOrderAccountScopeTests
|
||||||
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
|
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
|
||||||
Assert.Equal("Forbidden", missingEx.Code);
|
Assert.Equal("Forbidden", missingEx.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingLocationDataService : ILocationDataService
|
||||||
|
{
|
||||||
|
private readonly ILocationDataService _inner;
|
||||||
|
|
||||||
|
public RecordingLocationDataService(ILocationDataService inner)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CancellationToken LastScopeToken { get; private set; }
|
||||||
|
|
||||||
|
public Task<Locations?> GetByIdAsync(int id) => _inner.GetByIdAsync(id);
|
||||||
|
public Task<Locations?> GetByIdWithDetailsAsync(int id) => _inner.GetByIdWithDetailsAsync(id);
|
||||||
|
public Task<IEnumerable<Locations>> GetAllAsync() => _inner.GetAllAsync();
|
||||||
|
public Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId) => _inner.GetByAccountIdAsync(accountId);
|
||||||
|
public Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null)
|
||||||
|
=> _inner.GetPagedAsync(page, pageSize, search);
|
||||||
|
public Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null)
|
||||||
|
=> _inner.GetAddressbookPagedAsync(page, pageSize, search);
|
||||||
|
public Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
|
||||||
|
=> _inner.GetSiteOptionsAsync(search);
|
||||||
|
|
||||||
|
public Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
|
||||||
|
int locationId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
LastScopeToken = cancellationToken;
|
||||||
|
return _inner.GetAccountScopeAsync(locationId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Locations> AddAsync(Locations location) => _inner.AddAsync(location);
|
||||||
|
public Task UpdateAsync(Locations location) => _inner.UpdateAsync(location);
|
||||||
|
public Task DeleteAsync(int id) => _inner.DeleteAsync(id);
|
||||||
|
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
||||||
|
public Task<int> CountAsync() => _inner.CountAsync();
|
||||||
|
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||||
|
int page, int pageSize, string? search, CancellationToken cancellationToken)
|
||||||
|
=> _inner.GetListPagedAsync(page, pageSize, search, cancellationToken);
|
||||||
|
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
||||||
|
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
=> _inner.GetByIdForUpdateAsync(id, cancellationToken);
|
||||||
|
public Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
|
||||||
|
=> _inner.AddAsync(location, cancellationToken);
|
||||||
|
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)
|
||||||
|
=> _inner.UpdateAsync(location, cancellationToken);
|
||||||
|
public Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
=> _inner.DeleteByIdAsync(id, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ namespace SeaHavenIndustries.Tests;
|
||||||
internal static class WorkOrderAccountTestHelpers
|
internal static class WorkOrderAccountTestHelpers
|
||||||
{
|
{
|
||||||
public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context)
|
public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context)
|
||||||
=> new WorkOrderAccountResolver(new AccountDataService(context));
|
=> new WorkOrderAccountResolver(
|
||||||
|
new AccountDataService(context),
|
||||||
|
new LocationDataService(context));
|
||||||
|
|
||||||
public static ClaimsPrincipal AccountUser(
|
public static ClaimsPrincipal AccountUser(
|
||||||
string userId = "actor-1",
|
string userId = "actor-1",
|
||||||
|
|
@ -65,4 +67,53 @@ internal static class WorkOrderAccountTestHelpers
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task EnsureLocationAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int id = 1,
|
||||||
|
int? accountId = 1,
|
||||||
|
string name = "BK5")
|
||||||
|
{
|
||||||
|
if (await context.Locations.AnyAsync(l => l.Id == id))
|
||||||
|
return;
|
||||||
|
|
||||||
|
context.Locations.Add(new Locations
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
AccountId = accountId
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SeedBoardCreateScope(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int accountId = 1,
|
||||||
|
int locationId = 1,
|
||||||
|
string accountName = "Acme Corp")
|
||||||
|
{
|
||||||
|
if (!context.Accounts.Local.Any(a => a.Id == accountId)
|
||||||
|
&& !context.Accounts.Any(a => a.Id == accountId))
|
||||||
|
{
|
||||||
|
context.Accounts.Add(new Accounts
|
||||||
|
{
|
||||||
|
Id = accountId,
|
||||||
|
Name = accountName,
|
||||||
|
IsDeleted = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.Locations.Local.Any(l => l.Id == locationId)
|
||||||
|
&& !context.Locations.Any(l => l.Id == locationId))
|
||||||
|
{
|
||||||
|
context.Locations.Add(new Locations
|
||||||
|
{
|
||||||
|
Id = locationId,
|
||||||
|
Name = "BK5",
|
||||||
|
AccountId = accountId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
context.SaveChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ public class WorkOrderBoardCreateRelationalTests
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
|
||||||
|
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
|
|
@ -54,6 +55,7 @@ public class WorkOrderBoardCreateRelationalTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
Description = "Relational create"
|
Description = "Relational create"
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId);
|
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId);
|
||||||
|
|
||||||
|
|
@ -95,6 +97,7 @@ public class WorkOrderBoardCreateRelationalTests
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
|
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
|
||||||
|
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
|
|
@ -115,6 +118,7 @@ public class WorkOrderBoardCreateRelationalTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
Description = "Should roll back",
|
Description = "Should roll back",
|
||||||
PocContactId = 999_999
|
PocContactId = 999_999
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId));
|
}, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId));
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
var context = new ApplicationDbContext(options);
|
var context = new ApplicationDbContext(options);
|
||||||
|
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
|
||||||
|
|
@ -38,7 +39,8 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
||||||
|
|
@ -61,7 +63,8 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
Assert.Equal("SH00002", result.WoNumber);
|
Assert.Equal("SH00002", result.WoNumber);
|
||||||
|
|
@ -77,7 +80,8 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WoNumber = "12345",
|
WoNumber = "12345",
|
||||||
WorkOrderType = WorkOrderType.PO,
|
WorkOrderType = WorkOrderType.PO,
|
||||||
SiteCode = "DAL"
|
SiteCode = "DAL",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
Assert.Equal("00000012345", result.WoNumber);
|
Assert.Equal("00000012345", result.WoNumber);
|
||||||
|
|
@ -95,7 +99,8 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WoNumber = "99999",
|
WoNumber = "99999",
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||||
|
|
||||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||||
|
|
@ -110,6 +115,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
AssignTo = "dispatcher-1",
|
AssignTo = "dispatcher-1",
|
||||||
ScheduledDate = new DateTime(2026, 6, 25)
|
ScheduledDate = new DateTime(2026, 6, 25)
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -127,6 +133,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
ScheduleWeekOnly = true,
|
ScheduleWeekOnly = true,
|
||||||
TargetWeek = new DateOnly(2026, 6, 22)
|
TargetWeek = new DateOnly(2026, 6, 22)
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -145,6 +152,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
ScheduleWeekOnly = true
|
ScheduleWeekOnly = true
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +168,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
VendorId = 5,
|
VendorId = 5,
|
||||||
ApptDate = new DateTime(2026, 6, 26)
|
ApptDate = new DateTime(2026, 6, 26)
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -177,6 +186,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
Description = "Test WO"
|
Description = "Test WO"
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
|
|
@ -195,7 +205,8 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
||||||
|
|
@ -214,6 +225,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
PrimaryService = "HVAC PM",
|
PrimaryService = "HVAC PM",
|
||||||
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
|
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
|
||||||
ServiceNotes = "Unit on roof"
|
ServiceNotes = "Unit on roof"
|
||||||
|
|
@ -241,6 +253,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
SiteCode = "CHI",
|
SiteCode = "CHI",
|
||||||
|
LocationId = 1,
|
||||||
Trade = "Legacy Trade",
|
Trade = "Legacy Trade",
|
||||||
PrimaryService = "Plumbing"
|
PrimaryService = "Plumbing"
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -258,6 +271,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
ExtraServices = new List<string> { "Filter change" }
|
ExtraServices = new List<string> { "Filter change" }
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +285,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
PocName = "Jane Site Lead",
|
PocName = "Jane Site Lead",
|
||||||
PocPhone = "+1 555-0100",
|
PocPhone = "+1 555-0100",
|
||||||
PocNotes = "Call 30 min before"
|
PocNotes = "Call 30 min before"
|
||||||
|
|
@ -294,6 +309,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
TechPhone = "+1 555-0199",
|
TechPhone = "+1 555-0199",
|
||||||
VendorNotes = "Gate code 4421"
|
VendorNotes = "Gate code 4421"
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -317,6 +333,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
VendorId = 55,
|
VendorId = 55,
|
||||||
ApptDate = new DateTime(2026, 7, 18),
|
ApptDate = new DateTime(2026, 7, 18),
|
||||||
TechPhone = "+1 555-0199",
|
TechPhone = "+1 555-0199",
|
||||||
|
|
@ -343,6 +360,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
Trade = "HVAC",
|
Trade = "HVAC",
|
||||||
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
|
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -368,6 +386,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
VendorId = 999
|
VendorId = 999
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||||
|
|
||||||
|
|
@ -384,6 +403,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
ScheduledDate = new DateTime(2026, 6, 24),
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
IsAddOn = false
|
IsAddOn = false
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -405,6 +425,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
ScheduledDate = new DateTime(2099, 3, 10),
|
ScheduledDate = new DateTime(2099, 3, 10),
|
||||||
IsAddOn = true
|
IsAddOn = true
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
@ -424,6 +445,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
IsAddOn = true
|
IsAddOn = true
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
|
|
@ -441,6 +463,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
AvetaRequired = true
|
AvetaRequired = true
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
|
|
@ -460,7 +483,8 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
Assert.False(result.AvetaRequired);
|
Assert.False(result.AvetaRequired);
|
||||||
|
|
@ -480,7 +504,8 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.AddOn,
|
WorkOrderType = WorkOrderType.AddOn,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||||
|
|
||||||
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
|
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
|
||||||
|
|
@ -495,6 +520,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
PocName = "Primary Lead",
|
PocName = "Primary Lead",
|
||||||
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
||||||
{
|
{
|
||||||
|
|
@ -525,6 +551,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
AdditionalContacts = new List<WorkOrderAdditionalContactDto>()
|
AdditionalContacts = new List<WorkOrderAdditionalContactDto>()
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
|
|
@ -540,6 +567,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
||||||
{
|
{
|
||||||
new("", "", null),
|
new("", "", null),
|
||||||
|
|
@ -565,6 +593,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
||||||
{
|
{
|
||||||
new("Backup Lead", "", null)
|
new("Backup Lead", "", null)
|
||||||
|
|
@ -584,6 +613,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
||||||
{
|
{
|
||||||
new("", "+1 555-0101", null)
|
new("", "+1 555-0101", null)
|
||||||
|
|
@ -599,6 +629,7 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
SiteCode = "BK5",
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1,
|
||||||
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
AdditionalContacts = new List<WorkOrderAdditionalContactDto>
|
||||||
{
|
{
|
||||||
new("Backup Lead", "+1 555-0101", "After hours"),
|
new("Backup Lead", "+1 555-0101", "After hours"),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public class WorkOrderBoardCreateSyncLockTests
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
await using var context = new ApplicationDbContext(options);
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||||
|
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
|
|
@ -37,7 +38,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
||||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
||||||
|
|
||||||
var wo = await context.workOrders.SingleAsync();
|
var wo = await context.workOrders.SingleAsync();
|
||||||
|
|
@ -65,6 +67,7 @@ public class WorkOrderBoardCreateSyncLockTests
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
await using var context = new ApplicationDbContext(options);
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||||
|
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
|
|
@ -84,7 +87,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
||||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
||||||
|
|
||||||
var wo = await context.workOrders.SingleAsync();
|
var wo = await context.workOrders.SingleAsync();
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,17 @@ public class WorkOrderMigrationDiscoveryTests
|
||||||
|
|
||||||
Assert.Contains("20260824150000_SH117_AvetaRequired", migrations);
|
Assert.Contains("20260824150000_SH117_AvetaRequired", migrations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SH221_LocationAccountScope_is_discoverable_by_ef_runtime()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite("DataSource=:memory:")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
using var context = new ApplicationDbContext(options);
|
||||||
|
var migrations = context.Database.GetMigrations().ToList();
|
||||||
|
|
||||||
|
Assert.Contains("20260826120000_SH221_LocationAccountScope", migrations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ public class WorkOrderPhase7CoexistenceTests
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
await using var context = new ApplicationDbContext(options);
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||||
|
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
|
|
@ -80,7 +81,8 @@ public class WorkOrderPhase7CoexistenceTests
|
||||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
SiteCode = "BK5"
|
SiteCode = "BK5",
|
||||||
|
LocationId = 1
|
||||||
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
}, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
|
||||||
|
|
||||||
var wo = await context.workOrders.SingleAsync();
|
var wo = await context.workOrders.SingleAsync();
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
|
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
|
||||||
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
|
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
|
||||||
same account filter at service/data entry; authorize before storing blobs
|
same account filter at service/data entry; authorize before storing blobs
|
||||||
- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId`
|
- **Creates** (board): stamp `AccountId` from JWT `account_id` or `Location.AccountId`
|
||||||
from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip
|
via required `locationId`; body `customer`/`accountId` are not trusted. AddWorkorder,
|
||||||
|
ingest, webhook/recon, sync still stamp from claim or unique `Accounts.Name` ↔
|
||||||
|
`Customer`; unresolvable → reject/skip
|
||||||
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
|
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
|
||||||
|
|
||||||
## Context (historical)
|
## Context (historical)
|
||||||
|
|
@ -31,8 +33,13 @@ in review (fail-open) and replaced by the contract below.
|
||||||
AccountId). Not inferred from missing `account_id`.
|
AccountId). Not inferred from missing `account_id`.
|
||||||
4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
|
4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
|
||||||
5. **Create stamp**:
|
5. **Create stamp**:
|
||||||
- Authenticated + `account_id` → stamp claim (ignore client AccountId).
|
- Board `POST /workorders/board`: required `locationId`. Scoped → stamp
|
||||||
- Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else
|
JWT `account_id` only when `Location.AccountId` matches (else Forbidden).
|
||||||
|
Org-wide → stamp `Location.AccountId`. Missing location → NotFound;
|
||||||
|
null `Location.AccountId` → AccountUnresolved. Body `customer`/`accountId`
|
||||||
|
are ignored for the stamp.
|
||||||
|
- Legacy AddWorkorder: authenticated + `account_id` → stamp claim;
|
||||||
|
authenticated + `org_scope=all` → unique Customer→Accounts.Name; else
|
||||||
`AccountUnresolved`.
|
`AccountUnresolved`.
|
||||||
- Ingest / webhook / sync → same Customer resolution; unresolved create is
|
- Ingest / webhook / sync → same Customer resolution; unresolved create is
|
||||||
rejected or skipped (no null AccountId on new rows).
|
rejected or skipped (no null AccountId on new rows).
|
||||||
|
|
@ -51,8 +58,8 @@ in review (fail-open) and replaced by the contract below.
|
||||||
- Dispatcher/Manager/Supervisor/User without AccountId cannot access board,
|
- Dispatcher/Manager/Supervisor/User without AccountId cannot access board,
|
||||||
detail, search, list, or media until AccountId is assigned (or they are Admin
|
detail, search, list, or media until AccountId is assigned (or they are Admin
|
||||||
with `org_scope=all`).
|
with `org_scope=all`).
|
||||||
- Locations do not carry AccountId in the EF model; Customer name match is the
|
- Board create resolves from `Location.AccountId`. Customer name match remains
|
||||||
unauthenticated resolution path.
|
the ingest/webhook/legacy resolution path.
|
||||||
|
|
||||||
## Excepted rule
|
## Excepted rule
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue