Compare commits

..

2 commits

Author SHA1 Message Date
renovate[bot]
b3c3b39d4e
Merge 0f011f842c into 3b69b9fbcc 2026-09-25 05:51:08 +00:00
renovate[bot]
0f011f842c
chore(deps): update github actions 2026-09-25 05:51:04 +00:00
173 changed files with 847 additions and 23418 deletions

View file

@ -88,7 +88,7 @@ jobs:
review:
name: review
if: github.event_name != 'push'
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@22c47f924f313deb1c965ff3cd1667fc54d2e3ab # v1.0.12
ci-complete:
name: ci-complete

View file

@ -1,6 +0,0 @@
# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m,
# which returned 413 for every media upload over ~1 MB before the request reached
# the API. The cap sits above the API's own request limit
# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get
# the API's generic per-kind message instead of an nginx error page.
client_max_body_size 120M;

View file

@ -117,11 +117,11 @@ public class AuthenticationControllerTests
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded });
.ReturnsAsync(true);
var controller = NewController(service, "42");
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None);
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
@ -130,11 +130,11 @@ public class AuthenticationControllerTests
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus()
public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect });
.ReturnsAsync(false);
var controller = NewController(service, "42");
@ -143,70 +143,6 @@ public class AuthenticationControllerTests
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Old Password is incorrect");
response.Message.Should().Be("Current password is incorrect");
}
[Fact]
public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Password does not meet requirements");
response.Message.Should().Be(
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.");
}
[Fact]
public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword()
{
var service = new Mock<IAuthenticationService>();
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Passwords don't match");
service.Verify(
s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" },
CancellationToken.None);
var response = result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Your password could not be changed. Try again.");
response.Message.Should().NotContain("at least 6 characters");
}
[Fact]
public void ChangePassword_RequiresAuthenticatedCaller()
{
var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!;
method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
}
[Fact]

View file

@ -44,7 +44,7 @@ public class CalendarServiceTests
StartDate = startDate,
EndDate = startDate,
IsDeleted = isDeleted,
CreatedDate = DateTime.UtcNow
CreatedDate = DateTime.Now
};
ctx.Events.Add(ev);
ctx.SaveChanges();
@ -64,7 +64,6 @@ public class CalendarServiceTests
saved.Title.Should().Be("Standup");
saved.IsDeleted.Should().Be(false);
saved.CreatedDate.Should().NotBeNull();
saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]
@ -98,7 +97,6 @@ public class CalendarServiceTests
var updated = ctx.Events.Single();
updated.Title.Should().Be("New");
updated.LastModificationTime.Should().NotBeNull();
updated.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]
@ -136,7 +134,6 @@ public class CalendarServiceTests
var row = ctx.Events.Single();
row.IsDeleted.Should().Be(true);
row.DeletionTime.Should().NotBeNull();
row.DeletionTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]

View file

@ -40,8 +40,7 @@ public class DashboardControllerTests
{
ScheduledTomorrow = 3,
PendingUplifts = 5,
AvetaPending = 7,
Unassigned = 11
AvetaPending = 7
});
var result = await NewController(service).GetStats(
@ -51,6 +50,5 @@ public class DashboardControllerTests
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
((int)Prop(body, "pendingUplifts")).Should().Be(5);
((int)Prop(body, "avetaPending")).Should().Be(7);
((int)Prop(body, "unassigned")).Should().Be(11);
}
}

View file

@ -213,10 +213,10 @@ public class DashboardServiceTests
AccountUser(1), query, CancellationToken.None);
stats.Total.Should().Be(4);
stats.Breakdown.Overdue.Should().Be(0);
stats.Breakdown.Overdue.Should().Be(1);
stats.Breakdown.Other.Should().Be(0);
stats.Breakdown.PM.Should().Be(2);
stats.Breakdown.Emergency.Should().Be(1);
stats.Breakdown.Emergency.Should().Be(0);
stats.Breakdown.Reactive.Should().Be(1);
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);

View file

@ -1,249 +0,0 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// The Dashboard "Unassigned" count is open work orders with no
/// dispatcher in the selected period, and it equals the Work Orders list the
/// tile drills into (GET /board/search, Dispatcher = Unassigned).
/// </summary>
public class DashboardUnassignedTests
{
private static readonly DateOnly From = new(2026, 9, 21);
private static readonly DateOnly To = new(2026, 9, 25);
private static readonly List<LifecycleStatus> OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
.ToList();
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx)
=> new(new AccountDataService(ctx), new LocationDataService(ctx));
private static DashboardService NewDashboard(ApplicationDbContext ctx)
=> new(new DashboardDataService(ctx), Resolver(ctx));
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
=> new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx));
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1")
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static WorkOrder Wo(
int id,
DateTime? scheduled,
string? assignTo = null,
LifecycleStatus? status = LifecycleStatus.Scheduled,
DateOnly? targetWeek = null,
int accountId = 1,
string? legacyStatus = null,
string? statusText = null)
=> new()
{
Id = id,
AccountId = accountId,
InternalWONumber = $"2000000{id:0000}",
AssignTo = assignTo,
ScheduledDate = scheduled,
ScheduleWeekOnly = targetWeek.HasValue,
TargetWeek = targetWeek,
LifecycleStatus = status,
LegacyStatus = legacyStatus,
Status = statusText,
istemplate = false
};
// Rows written by legacy create paths, which set Status but no LifecycleStatus.
private static void SeedLegacy(ApplicationDbContext ctx)
{
var inRange = new DateTime(2026, 9, 22);
ctx.workOrders.AddRange(
Wo(13, inRange, status: null, statusText: "Open"), // counted
Wo(14, inRange, status: null, statusText: "On Hold"), // counted: Pending
Wo(15, inRange, status: null), // counted: no status at all
Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins
Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled
Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open"), // assigned
Wo(19, inRange, status: null, legacyStatus: "Open", statusText: "Done")); // counted: LegacyStatus wins
ctx.SaveChanges();
}
private static void Seed(ApplicationDbContext ctx)
{
ctx.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 22)), // counted
Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee
Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range
Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned
Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed),
Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled),
Wo(7, new DateTime(2026, 8, 4)), // out of range
Wo(8, null, status: LifecycleStatus.Incomplete), // undated
Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant
Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated
ctx.SaveChanges();
}
[Fact]
public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod()
{
using var ctx = NewContext();
Seed(ctx);
var stats = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
stats.Unassigned.Should().Be(3);
}
[Fact]
public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated()
{
using var ctx = NewContext();
Seed(ctx);
var stats = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
// 1, 2, 3, 7 (out of any week but open) and 8 (undated).
stats.Unassigned.Should().Be(5);
}
[Fact]
public async Task GetStatsAsync_CountsOpenLegacyRowsWithNoLifecycleStatus()
{
using var ctx = NewContext();
Seed(ctx);
SeedLegacy(ctx);
var inRange = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
var allTime = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
// 1, 2, 3 plus legacy 13, 14, 15 and 19.
inRange.Unassigned.Should().Be(7);
allTime.Unassigned.Should().Be(9);
}
[Fact]
public async Task BoardSearch_StatusFilter_MatchesLegacyRowsByTheirLegacyStatus()
{
using var ctx = NewContext();
var inRange = new DateTime(2026, 9, 22);
ctx.workOrders.AddRange(
Wo(1, inRange),
Wo(2, inRange, status: null, statusText: "scheduled"),
Wo(3, inRange, status: null, statusText: "Open"),
Wo(4, inRange, status: null, statusText: "Legacy Mystery"),
Wo(5, inRange, status: null));
ctx.SaveChanges();
var user = AccountUser(1);
async Task<IEnumerable<int>> Ids(LifecycleStatus status)
=> (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = From,
DateTo = To,
Statuses = new List<LifecycleStatus> { status },
PageSize = 200
}, user, "admin-1")).Items.Select(row => row.Id);
(await Ids(LifecycleStatus.Scheduled)).Should().BeEquivalentTo(new[] { 1, 2 });
// Unknown or missing legacy text reads as Incomplete, as the Phase0 backfill set it.
(await Ids(LifecycleStatus.Incomplete)).Should().BeEquivalentTo(new[] { 3, 4, 5 });
(await Ids(LifecycleStatus.Completed)).Should().BeEmpty();
}
[Fact]
public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders()
{
using var ctx = NewContext();
Seed(ctx);
var accountTwo = await NewDashboard(ctx).GetStatsAsync(
AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None);
var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync(
AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
accountTwo.Unassigned.Should().Be(2);
accountTwoInRange.Unassigned.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork()
{
using var ctx = NewContext();
Seed(ctx);
var dispatcher = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1, role: "Dispatcher", userId: "disp-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1),
new DashboardStatsQueryDTO { DispatcherId = "disp-1" },
CancellationToken.None);
dispatcher.Unassigned.Should().Be(0);
adminPickingDispatcher.Unassigned.Should().Be(0);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange)
{
using var ctx = NewContext();
Seed(ctx);
SeedLegacy(ctx);
// Dated outside the board's all-weeks window: neither side may count them.
ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1)));
ctx.SaveChanges();
var user = AccountUser(1);
var stats = await NewDashboard(ctx).GetStatsAsync(
user,
withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(),
CancellationToken.None);
// What the Work Orders board sends for the tile's drill-down link.
var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = withRange ? From : new DateOnly(2000, 1, 1),
DateTo = withRange ? To : new DateOnly(2099, 12, 31),
IncludeDateless = !withRange,
Dispatchers = new List<string> { "__unassigned__" },
Statuses = OpenStatuses,
PageSize = 200
}, user, "admin-1");
stats.Unassigned.Should().Be(list.TotalCount);
var listed = list.Items.Select(row => row.Id).ToList();
listed.Should().Contain(new[] { 13, 14, 15, 19 });
listed.Should().NotContain(new[] { 16, 17, 18 });
}
}

View file

@ -31,9 +31,7 @@ public class LocationControllerSitesTests
dataService,
new AccountDataService(ctx),
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
Mock.Of<IUpdateLocationValidation>());
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
{

View file

@ -286,80 +286,4 @@ public class LocationControllerTests
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
}
[Fact]
public async Task AddLocation_DuplicateSiteCode_Returns409WithStableCode()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
Prop(conflict.Value!, "Code").Should().Be("DuplicateSiteCode");
Prop(conflict.Value!, "Message").Should().Be("This site code already exists.");
}
[Fact]
public async Task EditLocation_DuplicateSiteCode_Returns409()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None);
result.Should().BeOfType<ConflictObjectResult>();
}
[Fact]
public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteForbiddenException(
SeaHaven.Services.Exceptions.SiteForbiddenException.DeleteDeniedMessage));
var result = await NewController(service).DeleteLocation(4, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(403);
forbidden.Value.Should().BeOfType<Response>().Which.Message.Should().Be("You are not allowed to delete sites.");
}
[Fact]
public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None))
.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
(await NewController(service).GetOpenWorkOrders(5, CancellationToken.None))
.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService()
{
var service = new Mock<ILocationService>();
SiteContactInfoRequestDTO? seen = null;
service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny<SiteContactInfoRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.Callback<int, SiteContactInfoRequestDTO, ClaimsPrincipal, CancellationToken>((_, request, _, _) => seen = request)
.Returns(Task.CompletedTask);
var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO
{
Contacts = new List<SiteContactInput_DTO> { new() { Id = 7, Name = "Main", Phone = "555-0100" } },
Notes = "Gate 4"
}, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
seen!.Notes.Should().Be("Gate 4");
seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" });
}
}

View file

@ -15,27 +15,6 @@ namespace Api.SeaHavenIndustries.Tests;
public class LocationServiceTests
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
{
if (request.AccountId == null)
{
if (!ctx.Accounts.Any(a => a.Id == 7))
{
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
ctx.SaveChanges();
}
request.AccountId = 7;
}
request.Address ??= "1 Depot Rd";
request.City ??= "Dallas";
request.State ??= "TX";
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@ -49,9 +28,7 @@ public class LocationServiceTests
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
new UpdateLocationValidation());
private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer")
{
@ -106,7 +83,7 @@ public class LocationServiceTests
SeedAccount(ctx, 9);
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Title = "Main WH",
@ -118,7 +95,7 @@ public class LocationServiceTests
ContactEmail = "wh@example.com",
Status = "Active",
AccountId = 9
}), OrgWideAdmin(), CancellationToken.None);
}, OrgWideAdmin(), CancellationToken.None);
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
@ -234,9 +211,7 @@ public class LocationServiceTests
dataService,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
new UpdateLocationValidation());
[Fact]
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
@ -262,14 +237,14 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "Old",
Name = "New",
Address = "9 New St",
City = "Plano",
Status = "Inactive"
}, OrgWideAdmin(), CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("Old", "the site code is immutable");
row.Name.Should().Be("New");
row.Address1.Should().Be("9 New St");
row.City.Should().Be("Plano");
row.Status.Should().Be("Inactive");
@ -288,7 +263,7 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "Old",
Name = "New",
City = "Plano"
}, OrgWideAdmin(), CancellationToken.None);
@ -326,12 +301,12 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(
existing.Id,
new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" },
new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" },
AccountUser(4),
CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("Owned");
row.Name.Should().Be("Renamed");
row.City.Should().Be("Austin");
row.AccountId.Should().Be(4);
}
@ -411,7 +386,7 @@ public class LocationServiceTests
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }),
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 },
OrgWideAdmin(),
CancellationToken.None);
@ -427,7 +402,7 @@ public class LocationServiceTests
ctx.SaveChanges();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }),
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 },
OrgWideAdmin(),
CancellationToken.None);
@ -443,7 +418,7 @@ public class LocationServiceTests
SeedAccount(ctx, 99);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }),
new LocationCreateRequestDTO { Name = "Site", AccountId = 99 },
AccountUser(4),
CancellationToken.None);
@ -478,7 +453,7 @@ public class LocationServiceTests
SeedAccount(ctx, 9);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
MissingScope(),
CancellationToken.None);
@ -501,14 +476,12 @@ public class LocationServiceTests
new LocationDataService(ctx),
accounts.Object,
new CreateLocationValidation(),
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
new UpdateLocationValidation());
using var cts = new CancellationTokenSource();
await service.CreateLocationFromRequestAsync(
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
OrgWideAdmin(),
cts.Token);
@ -547,6 +520,20 @@ public class LocationServiceTests
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
removed.Should().BeTrue();
again.Should().BeFalse();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
{

View file

@ -22,27 +22,6 @@ namespace Api.SeaHavenIndustries.Tests;
public class LocationSiteContactsTests
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
{
if (request.AccountId == null)
{
if (!ctx.Accounts.Any(a => a.Id == 7))
{
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
ctx.SaveChanges();
}
request.AccountId = 7;
}
request.Address ??= "1 Depot Rd";
request.City ??= "Dallas";
request.State ??= "TX";
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@ -56,9 +35,7 @@ public class LocationSiteContactsTests
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
new UpdateLocationValidation());
private static ClaimsPrincipal OrgWideAdmin()
{
@ -74,28 +51,27 @@ public class LocationSiteContactsTests
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
{
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Depot",
Phone = "555-9000",
AccountId = null,
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200"}
}
}), OrgWideAdmin(), CancellationToken.None);
}, OrgWideAdmin(), CancellationToken.None);
return ctx.Locations.Include(l => l.Contacts).Single();
}
[Fact]
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_KeepsSitePhoneIndependent_SetsAccountFromLocation()
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_SetsAccountFromLocation()
{
using var ctx = NewContext();
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
await ctx.SaveChangesAsync();
await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
AccountId = 7,
@ -104,10 +80,10 @@ public class LocationSiteContactsTests
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200" }
}
}), OrgWideAdmin(), CancellationToken.None);
}, OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts");
location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber");
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
contacts.Should().HaveCount(2);
@ -128,11 +104,11 @@ public class LocationSiteContactsTests
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>()
}), OrgWideAdmin(), CancellationToken.None);
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
@ -151,11 +127,11 @@ public class LocationSiteContactsTests
using var ctx = NewContext();
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}), OrgWideAdmin(), CancellationToken.None);
}, OrgWideAdmin(), CancellationToken.None);
if (expectedError == null)
{
@ -172,14 +148,14 @@ public class LocationSiteContactsTests
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = new string('x', 101), Phone = new string('5', 21) }
}
}), OrgWideAdmin(), CancellationToken.None);
}, OrgWideAdmin(), CancellationToken.None);
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).Which;
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100"));
@ -194,11 +170,11 @@ public class LocationSiteContactsTests
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
.ToList();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}), OrgWideAdmin(), CancellationToken.None);
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20"));
@ -244,7 +220,7 @@ public class LocationSiteContactsTests
}
[Fact]
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest()
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
@ -263,7 +239,7 @@ public class LocationSiteContactsTests
}, OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().BeNull("Site Phone comes from the request, not the first contact");
location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact");
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
active.Should().HaveCount(3);
@ -328,7 +304,7 @@ public class LocationSiteContactsTests
after.Should().HaveCount(snapshot.Count);
after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location));
ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber
.Should().Be("555-9000", "the rejected update must not persist any change");
.Should().Be("555-0100", "the rejected update must not persist any change");
}
[Fact]
@ -472,9 +448,7 @@ public class LocationSiteContactsTests
data.Object,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
new UpdateLocationValidation());
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
@ -515,6 +489,67 @@ public class LocationSiteContactsTests
rows.Should().BeEmpty();
}
[Fact]
public async Task DataService_DeleteByIdAsync_WithContacts_RemovesLocation_RetainsSoftDeletedDetachedContacts()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var alice = seeded.Contacts!.Single(c => c.FirstName == "Alice Cooper");
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
deleted.Should().BeTrue();
ctx.Locations.Should().BeEmpty();
var retained = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
retained.Should().HaveCount(2);
retained.Should().OnlyContain(c => c.IsDeleted == true);
retained.Should().OnlyContain(c => c.LocationId == null);
retained.Should().OnlyContain(c => c.DeletionTime != null);
retained.Should().OnlyContain(c => c.DeleterUserId == null, "the delete interface carries no actor");
retained.Single(c => c.Id == alice.Id).FirstName.Should().Be("Alice Cooper");
retained.Single(c => c.Id == alice.Id).PhoneNumber.Should().Be("555-0100");
retained.Single(c => c.Id == bob.Id).FirstName.Should().Be("Bob Dillon");
retained.Single(c => c.Id == bob.Id).PhoneNumber.Should().Be("555-0200");
}
[Fact]
public async Task DataService_DeleteByIdAsync_AlreadySoftDeletedContact_IsDetachedWithoutAuditRestamp()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
bob.IsDeleted = true;
bob.DeleterUserId = "admin-1";
bob.DeletionTime = new DateTime(2026, 1, 1);
await ctx.SaveChangesAsync();
var bobStamp = bob.DeletionTime;
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
deleted.Should().BeTrue();
ctx.Locations.Should().BeEmpty();
var retained = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
retained.LocationId.Should().BeNull("previously soft-deleted rows must also detach or the restrict FK blocks the delete");
retained.IsDeleted.Should().BeTrue();
retained.DeleterUserId.Should().Be("admin-1", "original audit stamp is preserved");
retained.DeletionTime.Should().Be(bobStamp);
}
[Fact]
public async Task DataService_DeleteByIdAsync_Missing_ReturnsFalse()
{
using var ctx = NewContext();
await SeedLocationWithContactsAsync(ctx);
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(424242, CancellationToken.None);
deleted.Should().BeFalse();
ctx.Locations.Should().HaveCount(1);
ctx.Contacts.Should().HaveCount(2);
}
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
{
public void UpExposed(MigrationBuilder builder) => Up(builder);
@ -573,7 +608,7 @@ public class LocationSiteContactsTests
using var json = JsonSerializer.SerializeToDocument(ok.Value);
var root = json.RootElement;
root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts");
root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact");
root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name");
var contacts = root.GetProperty("Contacts");
contacts.GetArrayLength().Should().Be(2);
@ -601,7 +636,7 @@ public class LocationSiteContactsTests
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
row.GetProperty("Contact").GetString().Should().Be("Alice Cooper");
row.GetProperty("Phone").GetString().Should().Be("555-9000");
row.GetProperty("Phone").GetString().Should().Be("555-0100");
row.GetProperty("Contacts").GetArrayLength().Should().Be(2);
}
}

View file

@ -7,13 +7,11 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
@ -548,7 +546,7 @@ public class NotificationFeedServiceTests
public async Task Controller_MapsForbiddenScopeTo403()
{
using var context = NewContext();
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
var controller = new NotificationsController(NewService(context))
{
ControllerContext = new ControllerContext
{
@ -567,7 +565,7 @@ public class NotificationFeedServiceTests
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
var controller = new NotificationsController(NewService(context))
{
ControllerContext = new ControllerContext
{

View file

@ -1,402 +0,0 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// SEV response-window alerts in the Notification Center: at risk from 50% of the window (banner set and a
/// dismissable row), breached from 100% (an acknowledge row that only acknowledging removes).
/// </summary>
public class NotificationSlaTests
{
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(Now);
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAccountResolver Resolver(ApplicationDbContext context)
=> new(new AccountDataService(context), new LocationDataService(context));
private static NotificationFeedService NewFeed(ApplicationDbContext context)
=> new(
new NotificationFeedDataService(context),
new VendorOperationsDataService(context, new DispatchDataService(context)),
Resolver(context),
new FixedTimeProvider());
private static SlaBreachAcknowledgementService NewAcknowledgement(ApplicationDbContext context)
=> new(
new NotificationFeedDataService(context),
new WorkOrderAuditDataService(context),
new UserDataService(context),
Resolver(context),
new FixedTimeProvider());
private static ClaimsPrincipal User(int accountId, string role, string userId = "disp-1")
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static WorkOrder Sla(
int id,
TimeSpan age,
string? severity = "1",
WorkOrderType? type = WorkOrderType.Reactive,
int accountId = 1,
string? assignTo = "disp-1",
DateTime? scheduled = null,
LifecycleStatus status = LifecycleStatus.Scheduled)
=> new()
{
Id = id,
AccountId = accountId,
AssignTo = assignTo,
InternalWONumber = $"{1000 + id}",
WorkOrderType = type,
Severity = severity,
CreatedDate = Now - age,
ScheduledDate = scheduled,
LifecycleStatus = status
};
private static TimeSpan Window(int severity) => SlaResponseWindows.Respond[severity];
private static NotificationSectionDto? SlaSection(NotificationFeedDto feed)
=> feed.Sections.SingleOrDefault(section => section.Reason == NotificationReasons.Sla);
private static IEnumerable<string> ItemIds(NotificationFeedDto feed)
=> SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty<string>();
[Fact]
public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge()
{
SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes)
.Should().Equal(new Dictionary<int, double>
{
[1] = 120,
[2] = 240,
[3] = 480,
[4] = 1440,
[5] = 4320
});
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task Thresholds_AtRiskFromHalfTheWindow_BreachedFromTheWholeWindow(int severity)
{
using var context = NewContext();
var second = TimeSpan.FromSeconds(1);
var window = Window(severity);
var level = severity.ToString();
context.workOrders.AddRange(
Sla(1, window / 2 - second, level),
Sla(2, window / 2, level),
Sla(3, window / 2 + second, level),
Sla(4, window - second, level),
Sla(5, window, level),
Sla(6, window + second, level));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
feed.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 2, 3, 4 });
ItemIds(feed).Should().BeEquivalentTo(
"sla-at-risk-2", "sla-at-risk-3", "sla-at-risk-4", "sla-breach-5", "sla-breach-6");
var section = SlaSection(feed)!;
section.Label.Should().Be("SLA at Risk");
section.Count.Should().Be(5);
section.Severity.Should().Be(NotificationSeverities.Critical);
section.Items.Where(item => item.Id.StartsWith("sla-breach-")).Should().OnlyContain(item =>
item.RowType == NotificationRowTypes.Acknowledge
&& item.Severity == NotificationSeverities.Critical
&& item.Title.EndsWith("missed its response deadline"));
section.Items.Where(item => item.Id.StartsWith("sla-at-risk-")).Should().OnlyContain(item =>
item.RowType == NotificationRowTypes.Dismissable
&& item.Severity == NotificationSeverities.High
&& item.Title.EndsWith("is at risk of missing its response deadline"));
}
[Fact]
public async Task AtRisk_CarriesTheServerComputedClock()
{
using var context = NewContext();
context.workOrders.Add(Sla(7, TimeSpan.FromMinutes(90), "1"));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
var atRisk = feed.SlaAtRisk.Should().ContainSingle().Subject;
atRisk.Number.Should().Be("1007");
atRisk.Severity.Should().Be(1);
atRisk.StartedAt.Should().Be(Now.AddMinutes(-90));
atRisk.DeadlineAt.Should().Be(Now.AddMinutes(30));
atRisk.DeadlineAt.Kind.Should().Be(DateTimeKind.Utc);
atRisk.PercentElapsed.Should().Be(75);
SlaSection(feed)!.Items.Single().Title.Should().Be("WO #1007 is at risk of missing its response deadline");
}
[Fact]
public async Task OnlyOpenReactiveOrEmergencyWorkOrdersWithASeverityLevelAreTracked()
{
using var context = NewContext();
var late = Window(1) * 2;
context.workOrders.AddRange(
Sla(1, late, "1", WorkOrderType.Reactive),
Sla(2, late, "2", WorkOrderType.Emergency),
Sla(3, late, "1", WorkOrderType.PM),
Sla(4, late, "1", WorkOrderType.Inspection),
Sla(5, late, "1", type: null),
Sla(6, late, severity: null),
Sla(7, late, severity: "9"),
Sla(8, late, "1", status: LifecycleStatus.Completed),
Sla(9, late, "1", status: LifecycleStatus.Canceled));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
}
[Fact]
public async Task TheClockStartsAtCreation_WhateverTheScheduledDate()
{
using var context = NewContext();
context.workOrders.AddRange(
Sla(1, Window(3) + TimeSpan.FromMinutes(1), "3", scheduled: null),
// Scheduled next week, but logged three days ago: the deadline is long gone.
Sla(2, TimeSpan.FromDays(3), "4", scheduled: Now.AddDays(7)),
// Scheduled days ago, but logged ten minutes ago: nothing is due yet.
Sla(3, TimeSpan.FromMinutes(10), "4", scheduled: Now.AddDays(-5)));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
feed.SlaAtRisk.Should().BeEmpty();
}
[Fact]
public async Task Dispatcher_OnlySeesTheirOwnWorkOrders_AndTheAccountStaysTheBoundary()
{
using var context = NewContext();
var atRisk = Window(2) * 3 / 4;
context.workOrders.AddRange(
Sla(1, atRisk, "2", assignTo: "disp-1"),
Sla(2, atRisk, "2", assignTo: "disp-2"),
Sla(3, atRisk, "2", assignTo: null),
Sla(4, atRisk, "2", assignTo: "disp-1", accountId: 2));
await context.SaveChangesAsync();
var dispatcher = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher", "disp-1"), CancellationToken.None);
var admin = await NewFeed(context).GetFeedAsync(User(1, "Admin", "admin-1"), CancellationToken.None);
var otherAccount = await NewFeed(context).GetFeedAsync(User(2, "Admin", "admin-2"), CancellationToken.None);
dispatcher.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(1);
ItemIds(dispatcher).Should().Equal("sla-at-risk-1");
admin.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 1, 2, 3 });
otherAccount.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(4);
}
[Fact]
public async Task Breaches_AreCappedSeparately_SoAtRiskRowsAlwaysShow()
{
using var context = NewContext();
for (var id = 1; id <= NotificationFeedService.SectionItemLimit + 10; id++)
context.workOrders.Add(Sla(id, Window(1) + TimeSpan.FromMinutes(id), "1"));
context.workOrders.Add(Sla(500, Window(5) * 3 / 4, "5"));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
var section = SlaSection(feed)!;
section.Count.Should().Be(NotificationFeedService.SectionItemLimit + 11);
section.Items.Count(item => item.RowType == NotificationRowTypes.Acknowledge)
.Should().Be(NotificationFeedService.SectionItemLimit);
section.Items.Should().Contain(item => item.Id == "sla-at-risk-500");
// The newest breaches are kept.
section.Items.Should().Contain(item => item.Id == "sla-breach-1");
section.Items.Should().NotContain(item => item.Id == $"sla-breach-{NotificationFeedService.SectionItemLimit + 10}");
}
[Fact]
public async Task Acknowledge_RecordsWhoAndWhenInTheAuditHistory_AndRemovesTheRow()
{
using var context = NewContext();
context.Users.Add(new ApplicationUser { Id = "disp-1", UserName = "jane", FirstName = "Jane", LastName = "Doe" });
context.workOrders.AddRange(
Sla(1, Window(1) + TimeSpan.FromMinutes(5), "1"),
Sla(2, Window(1) + TimeSpan.FromMinutes(9), "1"));
await context.SaveChangesAsync();
var user = User(1, "Dispatcher", "disp-1");
var outcome = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
outcome.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
var audit = await context.WorkOrderAuditLogs.SingleAsync();
audit.WorkOrderId.Should().Be(1);
audit.UserId.Should().Be("disp-1");
audit.CreatedAt.Should().Be(Now);
audit.Action.Should().Be("SLA breach acknowledged by Jane Doe");
audit.FieldName.Should().Be(SlaBreachAcknowledgementAudit.FieldName);
audit.NewValue.Should().Be("1");
audit.EventType.Should().Be("system");
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
ItemIds(feed).Should().Equal("sla-breach-2");
SlaSection(feed)!.Count.Should().Be(1);
var again = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
again.Should().Be(SlaBreachAcknowledgementOutcome.AlreadyAcknowledged);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(1);
}
[Fact]
public async Task Acknowledgement_CoversOnlyTheSeverityThatWasBreached()
{
using var context = NewContext();
var workOrder = Sla(1, TimeSpan.FromHours(30), "2");
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
var user = User(1, "Dispatcher", "disp-1");
await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
workOrder.Severity = "4";
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
ItemIds(feed).Should().Equal("sla-breach-1");
}
[Fact]
public async Task Acknowledge_IsRefusedOutsideTheCallersFeed()
{
using var context = NewContext();
var late = Window(1) * 2;
context.workOrders.AddRange(
Sla(1, late, "1", accountId: 2, assignTo: "disp-1"),
Sla(2, late, "1", assignTo: "disp-2"),
Sla(3, late, "1", status: LifecycleStatus.Completed),
Sla(4, late, "1", WorkOrderType.PM));
await context.SaveChangesAsync();
var service = NewAcknowledgement(context);
var dispatcher = User(1, "Dispatcher", "disp-1");
(await service.AcknowledgeAsync(dispatcher, 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(User(1, "Admin", "admin-1"), 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 2, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 3, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 4, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
var otherAccountAdmin = User(2, "Admin", "admin-2");
(await service.AcknowledgeAsync(otherAccountAdmin, 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
}
[Fact]
public async Task Acknowledge_BeforeTheDeadlineIsRefused()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 3 / 4, "1"));
await context.SaveChangesAsync();
var outcome = await NewAcknowledgement(context)
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, CancellationToken.None);
outcome.Should().Be(SlaBreachAcknowledgementOutcome.NotBreached);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
}
[Fact]
public async Task Acknowledge_WithoutANotificationRole_FailsClosed()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
await context.SaveChangesAsync();
var act = () => NewAcknowledgement(context).AcknowledgeAsync(User(1, "Vendor"), 1, CancellationToken.None);
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Theory]
[InlineData(1, "Dispatcher", 1, StatusCodes.Status204NoContent)]
[InlineData(2, "Admin", 1, StatusCodes.Status404NotFound)]
[InlineData(1, "Dispatcher", 2, StatusCodes.Status409Conflict)]
[InlineData(1, "Vendor", 1, StatusCodes.Status403Forbidden)]
public async Task Controller_MapsAcknowledgeOutcomes(int accountId, string role, int workOrderId, int expectedStatus)
{
using var context = NewContext();
context.workOrders.AddRange(
Sla(1, Window(1) * 2, "1"),
Sla(2, Window(1) * 3 / 4, "1"));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewFeed(context), NewAcknowledgement(context))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(accountId, role, "disp-1") }
}
};
var result = await controller.AcknowledgeSlaBreach(workOrderId, CancellationToken.None);
result.Should().BeAssignableTo<IStatusCodeActionResult>()
.Which.StatusCode.Should().Be(expectedStatus);
}
[Fact]
public async Task Acknowledge_ForwardsCancellation()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
await context.SaveChangesAsync();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => NewAcknowledgement(context)
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
}
}

View file

@ -1,218 +0,0 @@
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Exercises the password rule through the same Identity registration the API
/// host uses, so these assertions describe the rule that runs in production.
/// </summary>
public sealed class PasswordPolicyTests : IAsyncDisposable
{
private const string CurrentPassword = "Current1!";
private readonly ServiceProvider _provider;
private readonly AsyncServiceScope _scope;
public PasswordPolicyTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddSeaHavenIdentity();
_provider = services.BuildServiceProvider();
_scope = _provider.CreateAsyncScope();
}
private UserManager<ApplicationUser> UserManager =>
_scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
[Theory]
[InlineData("Ab1!x", "PasswordTooShort")]
[InlineData("abc12!", "PasswordRequiresUpper")]
[InlineData("Abcde!", "PasswordRequiresDigit")]
[InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")]
public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Select(error => error.Code).Should().Equal(expectedCode);
}
[Theory]
[InlineData("Abc1!x")]
[InlineData("ABC12!")]
public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Should().BeEmpty();
}
[Fact]
public void Registration_AppliesSharedPolicyOptions()
{
var options = _scope.ServiceProvider.GetRequiredService<IOptions<IdentityOptions>>().Value.Password;
options.RequiredLength.Should().Be(6);
options.RequireUppercase.Should().BeTrue();
options.RequireDigit.Should().BeTrue();
options.RequireNonAlphanumeric.Should().BeTrue();
options.RequireLowercase.Should().BeFalse();
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.PasswordRejected);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Succeeded);
var reloaded = await UserManager.FindByIdAsync(user.Id);
(await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue();
}
[Theory]
[InlineData("ConcurrencyFailure")]
[InlineData("PasswordMismatch")]
[InlineData("DefaultError")]
public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code)
{
var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" };
var userManager = new Mock<UserManager<ApplicationUser>>(
Mock.Of<IUserStore<ApplicationUser>>(), null!, null!, null!, null!, null!, null!, null!, null!);
userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user);
userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true);
userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x"))
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" }));
var service = new AuthenticationService(
userManager.Object,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Failed);
}
[Theory]
[InlineData("PasswordTooShort", true)]
[InlineData("PasswordRequiresUpper", true)]
[InlineData("PasswordRequiresDigit", true)]
[InlineData("PasswordRequiresNonAlphanumeric", true)]
[InlineData("ConcurrencyFailure", false)]
[InlineData("PasswordMismatch", false)]
public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected)
{
IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code }))
.Should().Be(expected);
}
[Fact]
public async Task ChangePassword_CancelledToken_Throws()
{
var service = NewAuthenticationService();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
var service = NewAuthenticationService(forget);
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
reset.Should().BeFalse();
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
{
var errors = new List<IdentityError>();
foreach (var validator in UserManager.PasswordValidators)
{
var result = await validator.ValidateAsync(UserManager, user, password);
errors.AddRange(result.Errors);
}
return errors;
}
private async Task<ApplicationUser> CreateUserAsync()
{
var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" };
var created = await UserManager.CreateAsync(user, CurrentPassword);
created.Succeeded.Should().BeTrue();
return user;
}
private AuthenticationService NewAuthenticationService(Mock<IForgetPasswordDataService>? forget = null) =>
new(
UserManager,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>());
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
}
}

View file

@ -1,105 +0,0 @@
using System.Net;
using Api.SeaHavenIndustries.Helper;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using SendGrid;
using SendGrid.Helpers.Mail;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class SendMessageTests
{
private const string Recipient = "taylor@example.com";
private const string Subject = "You're invited to Seahaven";
private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}";
[Theory]
[InlineData(HttpStatusCode.OK, true)]
[InlineData(HttpStatusCode.Accepted, true)]
[InlineData(HttpStatusCode.BadRequest, false)]
[InlineData(HttpStatusCode.Unauthorized, false)]
[InlineData(HttpStatusCode.TooManyRequests, false)]
[InlineData(HttpStatusCode.InternalServerError, false)]
public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered)
{
var sender = new StubbedSendMessage(status, new CapturingLogger());
Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 }));
Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "<p>Hi</p>", null));
}
[Fact]
public async Task ARejectedSend_LogsOnlyTheStatusCode()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>secret link</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains("400", entry);
Assert.DoesNotContain(Recipient, entry);
Assert.DoesNotContain(Subject, entry);
Assert.DoesNotContain("secret link", entry);
Assert.DoesNotContain("errors", entry);
}
[Fact]
public async Task AFailedSend_LogsOnlyTheExceptionType()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(
new HttpRequestException($"could not reach SendGrid for {Recipient}"),
logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains(nameof(HttpRequestException), entry);
Assert.DoesNotContain(Recipient, entry);
}
private sealed class StubbedSendMessage : SendMessage
{
private readonly Mock<ISendGridClient> _client = new();
public StubbedSendMessage(HttpStatusCode status, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null));
}
public StubbedSendMessage(Exception failure, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(failure);
}
protected override ISendGridClient CreateClient(string? apiKey) => _client.Object;
}
private sealed class CapturingLogger : ILogger<SendMessage>
{
public List<string> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add($"{formatter(state, exception)} {exception}");
}
}

View file

@ -1,503 +0,0 @@
using System.Security.Claims;
using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Site registry rules: tenant-scoped unique site codes, immutable codes,
/// permission-gated tombstone delete, open work order lookup and the
/// contact/notes write used by the work-order Site dialog.
/// </summary>
public class SiteRegistryServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx, string? role = "Admin") =>
NewService(new LocationDataService(ctx), new AccountDataService(ctx), role);
private static LocationService NewService(
ILocationDataService data,
IAccountDataService accounts,
string? role = "Admin")
{
var permissions = new Mock<ITeamPermissionOverrideDataService>();
permissions
.Setup(p => p.GetUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string userId, CancellationToken _) => role == null
? null
: new TeamPermissionUserData { UserId = userId, RoleName = role });
return new LocationService(
data,
accounts,
new CreateLocationValidation(),
new UpdateLocationValidation(),
permissions.Object,
new TeamPermissionPolicy());
}
private static ClaimsPrincipal OrgWide(string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin") =>
Principal(role, new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()));
private static ClaimsPrincipal Principal(string role, Claim scope) =>
new(new ClaimsIdentity(new List<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, role),
scope
}, "test"));
private static void SeedAccounts(ApplicationDbContext ctx, params int[] ids)
{
foreach (var id in ids)
ctx.Accounts.Add(new Accounts { Id = id, Name = $"Client {id}", IsDeleted = false });
ctx.SaveChanges();
}
private static Locations SeedSite(ApplicationDbContext ctx, string code, int? accountId, bool deleted = false)
{
var site = new Locations { Name = code, AccountId = accountId, City = "Dallas", State = "TX", IsDeleted = deleted ? true : null };
ctx.Locations.Add(site);
ctx.SaveChanges();
return site;
}
private static WorkOrder Wo(
int id,
int? locationId,
LifecycleStatus? status = LifecycleStatus.Incomplete,
int? accountId = 1,
string? siteCode = null,
string? legacyStatus = null,
bool deleted = false) => new()
{
Id = id,
LocationId = locationId,
LifecycleStatus = status,
LegacyStatus = legacyStatus,
AccountId = accountId,
SiteCode = siteCode,
IsDeleted = deleted ? true : null
};
private static LocationCreateRequestDTO CreateRequest(string code, int? accountId) => new()
{
Name = code,
AccountId = accountId,
Address = "1 Depot Rd",
City = "Dallas",
State = "TX",
Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } }
};
[Fact]
public async Task Create_DuplicateSiteCodeInSameClient_IsRejectedCaseInsensitively()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" bk5 ", 1), OrgWide(), CancellationToken.None);
await act.Should().ThrowAsync<SiteCodeConflictException>();
ctx.Locations.Should().ContainSingle();
}
[Fact]
public async Task Create_SameSiteCodeForAnotherClient_IsAllowed()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1, 2);
SeedSite(ctx, "BK5", 1);
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 2), OrgWide(), CancellationToken.None);
ctx.Locations.Where(l => l.Name == "BK5").Select(l => l.AccountId).Should().BeEquivalentTo(new int?[] { 1, 2 });
}
[Fact]
public async Task Create_SiteCodeOfADeletedSite_CanBeReused()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1, deleted: true);
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), CancellationToken.None);
ctx.Locations.Count(l => l.Name == "BK5" && l.IsDeleted != true).Should().Be(1);
}
[Fact]
public async Task Create_BlankSiteCode_IsAValidationError()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" ", 1), OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code is required.");
}
[Fact]
public async Task Create_StoresTrimmedCodeNotesAndSitePhoneIndependentOfContacts()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var request = CreateRequest(" DFW8 ", 1);
request.Phone = "555-9000";
request.Notes = " Gate code 4411 ";
await NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
var site = ctx.Locations.Single();
site.Name.Should().Be("DFW8");
site.PhoneNumber.Should().Be("555-9000");
site.Notes.Should().Be("Gate code 4411");
}
[Fact]
public async Task Update_ChangingAnExistingSiteCode_IsRejected()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
site.Id, new LocationUpdateRequestDTO { Name = "BK6" }, OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code cannot be changed.");
ctx.Locations.AsNoTracking().Single().Name.Should().Be("BK5");
}
[Fact]
public async Task Update_KeepsCodeAndNotesWhenTheRequestOmitsThem()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
site.Notes = "Dock 3";
site.Status = "Active";
ctx.SaveChanges();
await NewService(ctx).UpdateLocationFromRequestAsync(
site.Id, new LocationUpdateRequestDTO { Name = "bk5", City = "Memphis" }, OrgWide(), CancellationToken.None);
var saved = ctx.Locations.AsNoTracking().Single();
saved.Name.Should().Be("BK5");
saved.City.Should().Be("Memphis");
saved.Notes.Should().Be("Dock 3");
saved.Status.Should().Be("Active");
}
[Fact]
public async Task Update_BlankLegacyCode_CanBeFilledOnceButMustBeUniqueInTheClient()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1);
var legacy = SeedSite(ctx, "", 1);
var duplicate = () => NewService(ctx).UpdateLocationFromRequestAsync(
legacy.Id, new LocationUpdateRequestDTO { Name = "bk5" }, OrgWide(), CancellationToken.None);
await duplicate.Should().ThrowAsync<SiteCodeConflictException>();
await NewService(ctx).UpdateLocationFromRequestAsync(
legacy.Id, new LocationUpdateRequestDTO { Name = "MEM1" }, OrgWide(), CancellationToken.None);
ctx.Locations.AsNoTracking().Single(l => l.Id == legacy.Id).Name.Should().Be("MEM1");
}
[Theory]
[InlineData("Admin")]
[InlineData("Scheduler")]
public async Task Delete_AdminOrScheduler_TombstonesTheSiteAndLeavesWorkOrdersAsTheyWere(string role)
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var site = SeedSite(ctx, "BK5", 1);
ctx.Contacts.Add(new Contacts { LocationId = site.Id, FirstName = "Main", PhoneNumber = "555-0100" });
ctx.workOrders.Add(Wo(10, site.Id));
ctx.SaveChanges();
var deleted = await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None);
deleted.Should().BeTrue();
var tombstone = ctx.Locations.AsNoTracking().Single();
tombstone.IsDeleted.Should().BeTrue();
tombstone.DeleterUserId.Should().Be("actor-1");
ctx.workOrders.AsNoTracking().Single().LocationId.Should().Be(site.Id, "work orders keep their site reference");
ctx.Contacts.AsNoTracking().Single().IsDeleted.Should().NotBe(true, "contacts still back open work orders");
var data = new LocationDataService(ctx);
(await data.GetDetailByIdAsync(site.Id, CancellationToken.None)).Should().BeNull();
(await data.GetSiteOptionsAsync(null, CancellationToken.None)).Should().BeEmpty();
(await data.GetListPagedAsync(1, 10, null, null, CancellationToken.None)).TotalCount.Should().Be(0);
(await data.GetAccountScopeAsync(site.Id, CancellationToken.None)).Exists.Should().BeFalse("a deleted site cannot be assigned to new work orders");
(await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None)).Should().BeFalse();
}
[Theory]
[InlineData("Dispatcher")]
[InlineData(null)]
public async Task Delete_WithoutDeleteSitesPermission_IsForbiddenAndKeepsTheSite(string? role)
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var act = () => NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role ?? "Dispatcher"), CancellationToken.None);
await act.Should().ThrowAsync<SiteForbiddenException>();
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
}
[Fact]
public async Task Delete_SiteOfAnotherClient_IsRejectedForAnAccountScopedCaller()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 2);
var act = () => NewService(ctx).DeleteLocationByIdAsync(site.Id, AccountUser(1), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
}
[Fact]
public async Task OpenWorkOrders_ExcludeTerminalDeletedAndOtherSitesWork()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
var other = SeedSite(ctx, "MEM1", 1);
ctx.workOrders.AddRange(
Wo(1, site.Id),
Wo(2, site.Id, LifecycleStatus.Scheduled),
Wo(3, site.Id, LifecycleStatus.Completed),
Wo(4, site.Id, LifecycleStatus.Canceled),
Wo(5, site.Id, deleted: true),
Wo(6, site.Id, status: null, legacyStatus: "Completed"),
Wo(7, site.Id, status: null, legacyStatus: "Open"),
Wo(8, null, siteCode: "bk5"),
Wo(9, null, siteCode: "BK5", accountId: 2),
Wo(10, other.Id));
ctx.SaveChanges();
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
result!.WorkOrderIds.Should().Equal(1, 2, 7, 8);
result.Count.Should().Be(4);
}
[Fact]
public async Task OpenWorkOrders_AccountScopedCaller_SeesOnlyTheirClientsWork()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
ctx.workOrders.AddRange(Wo(1, site.Id, accountId: 1), Wo(2, site.Id, accountId: 2));
ctx.SaveChanges();
var own = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(1), CancellationToken.None);
own!.WorkOrderIds.Should().Equal(1);
var foreign = () => NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(2), CancellationToken.None);
await foreign.Should().ThrowAsync<UnauthorizedAccessException>();
}
[Fact]
public async Task OpenWorkOrders_ReturnFullCountButCapIds()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
ctx.workOrders.AddRange(Enumerable.Range(1, LocationService.MaxOpenWorkOrderIds + 5).Select(id => Wo(id, site.Id)));
ctx.SaveChanges();
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
result!.Count.Should().Be(LocationService.MaxOpenWorkOrderIds + 5);
result.WorkOrderIds.Should().HaveCount(LocationService.MaxOpenWorkOrderIds);
}
[Fact]
public async Task OpenWorkOrders_MissingOrDeletedSite_ReturnsNull()
{
using var ctx = NewContext();
var deleted = SeedSite(ctx, "BK5", 1, deleted: true);
(await NewService(ctx).GetOpenWorkOrdersAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeNull();
(await NewService(ctx).GetOpenWorkOrdersAsync(999, OrgWide(), CancellationToken.None)).Should().BeNull();
}
[Fact]
public async Task UpdateSiteContactInfo_SavesContactsAndNotesToTheSiteRecord()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var main = new Contacts { LocationId = site.Id, FirstName = "Old Main", PhoneNumber = "555-0100", SiteContactOrder = 0 };
ctx.Contacts.Add(main);
ctx.SaveChanges();
await NewService(ctx).UpdateSiteContactInfoAsync(site.Id, new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO>
{
new() { Id = main.Id, Name = "New Main", Phone = "555-0199" },
new() { Name = "Night Shift", Phone = "555-0200" }
},
Notes = " Call ahead "
}, OrgWide("Dispatcher"), CancellationToken.None);
var saved = ctx.Locations.AsNoTracking().Include(l => l.Contacts).Single();
saved.Notes.Should().Be("Call ahead");
saved.Contacts!.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder)
.Select(c => (c.Id == main.Id, c.FirstName, c.PhoneNumber))
.Should().Equal((true, "New Main", "555-0199"), (false, "Night Shift", "555-0200"));
}
[Fact]
public async Task UpdateSiteContactInfo_RejectsOutOfScopeDeletedAndContactlessRequests()
{
using var ctx = NewContext();
var foreign = SeedSite(ctx, "BK5", 2);
var deleted = SeedSite(ctx, "MEM1", 1, deleted: true);
var request = new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
};
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None)))
.Should().ThrowAsync<UnauthorizedAccessException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<KeyNotFoundException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(
foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<FluentValidation.ValidationException>();
}
[Fact]
public async Task NewSiteOperations_ForwardTheCallersCancellationToken()
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var site = new Locations { Id = 5, Name = "BK5", AccountId = 1, Contacts = new List<Contacts>() };
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetByIdForUpdateAsync(5, token)).ReturnsAsync(site);
data.Setup(d => d.GetDetailByIdAsync(5, token)).ReturnsAsync(site);
data.Setup(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token))
.ReturnsAsync((1, new[] { 7 }));
var service = NewService(data.Object, Mock.Of<IAccountDataService>());
await service.GetOpenWorkOrdersAsync(5, OrgWide(), token);
await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
}, OrgWide(), token);
await service.DeleteLocationByIdAsync(5, OrgWide(), token);
data.Verify(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token), Times.Once);
data.Verify(d => d.UpdateAsync(site, token), Times.Exactly(2));
}
[Fact]
public async Task Create_DuplicateCheck_ForwardsTheCallersCancellationToken()
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var data = new Mock<ILocationDataService>();
data.Setup(d => d.SiteCodeExistsAsync("BK5", 1, null, token)).ReturnsAsync(true);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsActiveAsync(1, token)).ReturnsAsync(true);
var service = NewService(data.Object, accounts.Object);
var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token);
await act.Should().ThrowAsync<SiteCodeConflictException>();
data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once);
}
[Fact]
public void CompletionSnapshot_FreezesSiteNotesWhenTheWorkOrderHasNone()
{
var workOrder = new WorkOrder
{
Id = 1,
Locations = new Locations { Id = 5, Name = "BK5", Notes = "Gate code 4411", Contacts = new List<Contacts>() }
};
WorkOrderCompletionSnapshotMapper.Capture(workOrder);
using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!);
frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411");
}
public static TheoryData<string, Action<LocationCreateRequestDTO>, string> MissingSiteFields => new()
{
{ "no client", r => r.AccountId = null, "Client is required." },
{ "no street address", r => r.Address = " ", "Street Address is required." },
{ "no city", r => r.City = null, "City is required." },
{ "no state", r => r.State = "", "State is required." },
{ "no contacts list", r => r.Contacts = null, "At least one contact is required." },
{ "empty contacts list", r => r.Contacts = new List<SiteContactRequestDTO>(), "At least one contact is required." },
{ "contact without phone", r => r.Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = " " } }, "Contact phone is required." }
};
[Theory]
[MemberData(nameof(MissingSiteFields))]
public async Task Create_WithoutARequiredSiteField_IsAValidationErrorAndStoresNothing(
string _,
Action<LocationCreateRequestDTO> strip,
string expectedMessage)
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var request = CreateRequest("BK9", 1);
strip(request);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().Contain(e => e.ErrorMessage == expectedMessage);
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task Delete_RemovesTheSiteFromEveryLegacyRead()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var deleted = SeedSite(ctx, "BK5", 1);
var kept = SeedSite(ctx, "BK6", 1);
var service = NewService(ctx);
(await service.DeleteLocationByIdAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeTrue();
(await service.GetLocationByIdAsync(deleted.Id)).Should().BeNull();
(await service.GetLocationByIdWithDetailsAsync(deleted.Id)).Should().BeNull();
(await service.LocationExistsAsync(deleted.Id)).Should().BeFalse();
(await service.GetTotalLocationCountAsync()).Should().Be(1);
(await service.GetAllLocationsAsync()).Select(l => l.Id).Should().Equal(kept.Id);
(await service.GetLocationsByAccountIdAsync(1)).Select(l => l.Id).Should().Equal(kept.Id);
(await service.GetLocationsPagedAsync(1, 10)).Items.Select(l => l.Id).Should().Equal(kept.Id);
(await new LocationDataService(ctx).GetAddressbookPagedAsync(1, 10)).TotalCount.Should().Be(1);
(await new VendorOperationsDataService(ctx, Mock.Of<IDispatchDataService>()).LocationExistsAsync(deleted.Id, CancellationToken.None)).Should().BeFalse();
}
}

View file

@ -1,171 +0,0 @@
using System.Reflection;
using System.Text.Json;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class TeamMemberInviteControllerTests
{
[Fact]
public void RegistrationRequests_CarryNoUserIdentifier()
{
var requestTypes = new[]
{
typeof(TeamMemberInviteTokenRequestDTO),
typeof(VerifyTeamMemberInviteCodeRequestDTO),
typeof(CompleteTeamMemberRegistrationRequestDTO)
};
var properties = requestTypes
.SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.Select(property => property.Name)
.Distinct()
.OrderBy(name => name);
properties.Should().Equal("Code", "Password", "Phone", "Token");
}
[Fact]
public void RegistrationEndpoints_AreAnonymousAndNeverCached()
{
var type = typeof(TeamMemberInviteController);
type.GetCustomAttribute<AllowAnonymousAttribute>().Should().NotBeNull();
var cache = type.GetCustomAttribute<ResponseCacheAttribute>();
cache.Should().NotBeNull();
cache!.NoStore.Should().BeTrue();
}
[Fact]
public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter()
{
var filter = typeof(TeamMemberInviteController).GetCustomAttribute<TypeFilterAttribute>();
filter.Should().NotBeNull();
filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter));
}
[Fact]
public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail()
{
var context = ExceptionContextFor(new ArgumentException(
"SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'"));
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeTrue();
var result = context.Result.Should().BeOfType<ObjectResult>().Subject;
result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var body = JsonSerializer.Serialize(result.Value);
body.Should().Be(
"{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}");
body.Should().NotContain("SELECT").And.NotContain("leak-me");
}
[Fact]
public void ExceptionFilter_LeavesCancellationToTheHost()
{
var context = ExceptionContextFor(new OperationCanceledException());
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeFalse();
context.Result.Should().BeNull();
}
private static ExceptionContext ExceptionContextFor(Exception exception)
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
return new ExceptionContext(actionContext, new List<IFilterMetadata>()) { Exception = exception };
}
[Theory]
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
[InlineData(TeamMemberRegistrationStatus.Ok)]
public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status)
{
var service = new Mock<ITeamMemberRegistrationService>();
service.Setup(x => x.CompleteAsync(It.IsAny<CompleteTeamMemberRegistrationRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status });
var controller = new TeamMemberInviteController(service.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None);
var failure = result.Should().BeOfType<ObjectResult>().Subject;
failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
failure.Value.Should().BeEquivalentTo(new
{
code = "invalid_invite",
message = TeamMemberInviteController.InvalidInviteMessage
});
}
[Fact]
public void ResendInvite_RequiresAuthenticatedCaller()
{
typeof(TeamMemberController).GetCustomAttribute<AuthorizeAttribute>().Should().NotBeNull();
typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))!
.GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
}
[Fact]
public async Task ResendInvite_Success_ReturnsInviteSent()
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" });
}
[Theory]
[InlineData("Forbidden", typeof(ForbidResult))]
[InlineData("Team member not found.", typeof(NotFoundObjectResult))]
[InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))]
[InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))]
public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected)
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType(expected);
}
private static TeamMemberController NewTeamMemberController(Mock<ITeamMemberInviteService> invites)
{
return new TeamMemberController(Mock.Of<ITeamMemberService>(), Mock.Of<ITeamPermissionService>(), invites.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"))
}
}
};
}
}

View file

@ -1,98 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class TeamMemberPermissionsEndpointTests
{
[Fact]
public async Task Signed_in_caller_receives_their_effective_keys_as_a_permissions_array()
{
var data = new Mock<ITeamPermissionOverrideDataService>();
data.Setup(d => d.GetUserAsync("u1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamPermissionUserData
{
UserId = "u1",
RoleName = "Dispatcher",
Overrides = new Dictionary<string, UserPermissionState>
{
[TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow
}
});
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer")));
var ok = Assert.IsType<OkObjectResult>(await controller.GetMyPermissions(CancellationToken.None));
var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var document = JsonDocument.Parse(json);
var keys = document.RootElement.GetProperty("permissions").EnumerateArray()
.Select(element => element.GetString()).ToList();
Assert.Contains(TeamPermissionKeys.CreateCompletionDocTemplates, keys);
Assert.DoesNotContain(TeamPermissionKeys.DeleteCompletionDocTemplates, keys);
}
[Fact]
public async Task Unauthenticated_caller_gets_401_without_data_access()
{
var data = new Mock<ITeamPermissionOverrideDataService>(MockBehavior.Strict);
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity()));
var result = Assert.IsType<UnauthorizedResult>(await controller.GetMyPermissions(CancellationToken.None));
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
}
[Fact]
public void Route_is_get_me_permissions_and_requires_authentication()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore().AddApplicationPart(typeof(TeamMemberController).Assembly);
using var provider = services.BuildServiceProvider();
var descriptor = provider
.GetRequiredService<IActionDescriptorCollectionProvider>()
.ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Single(d => d.ControllerTypeInfo == typeof(TeamMemberController)
&& d.ActionName == nameof(TeamMemberController.GetMyPermissions));
var methods = descriptor.ActionConstraints!.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods);
Assert.Equal(new[] { "GET" }, methods);
Assert.Equal("api/team-members/me/permissions", descriptor.AttributeRouteInfo!.Template);
Assert.NotEmpty(typeof(TeamMemberController).GetCustomAttributes(typeof(AuthorizeAttribute), true));
Assert.Empty(descriptor.MethodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true));
}
private static TeamMemberController Controller(
ITeamPermissionOverrideDataService data,
ClaimsPrincipal user) =>
new(
Mock.Of<ITeamMemberService>(),
new TeamPermissionService(data, new TeamPermissionPolicy()),
Mock.Of<ITeamMemberInviteService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
}
};
}

View file

@ -28,8 +28,7 @@ public sealed class TeamMemberServiceTests
Mock.Of<IUserServiceAreaDataService>(),
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>());
Mock.Of<ITeamPermissionService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
@ -409,8 +408,7 @@ public sealed class TeamMemberServiceTests
areas.Object,
overrides.Object,
userData.Object,
permissions.Object,
Mock.Of<ITeamMemberInviteService>());
permissions.Object);
}
private static Mock<UserManager<ApplicationUser>> UserManager()

View file

@ -164,59 +164,6 @@ public sealed class UpliftQueueReadTests
new DateTime(2026, 3, 10));
}
[Fact]
public async Task List_RejectedStatus_OrdersMostRecentlyRejectedFirst()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
// Request order (3/1, 3/2, 3/3) deliberately disagrees with decision order.
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)),
Request(dispatch, "Rejected", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)),
Request(dispatch, "Rejected", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.DecidedAt).Should().Equal(
new DateTime(2026, 3, 20),
new DateTime(2026, 3, 15),
new DateTime(2026, 3, 10));
}
[Fact]
public async Task List_RejectedStatus_ReturnsOnlyRejectedRequests_IncludingLegacyDenied()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 3)),
Request(dispatch, "Revoked", new DateTime(2026, 3, 4), decided: new DateTime(2026, 3, 5)),
Request(dispatch, "Withdrawn", new DateTime(2026, 3, 6)),
Request(dispatch, "Rejected", new DateTime(2026, 3, 7), decided: new DateTime(2026, 3, 8)),
Request(dispatch, "Denied", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None);
result.Total.Should().Be(2);
result.Items.Select(i => i.Status).Should().Equal("Rejected", "Rejected");
result.Items.Select(i => i.DecidedAt).Should().Equal(
new DateTime(2026, 3, 8),
new DateTime(2026, 2, 2));
}
[Fact]
public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder()
{
@ -611,40 +558,6 @@ public sealed class UpliftQueueReadTests
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
}
[Fact]
public async Task List_RejectedRow_CarriesRejecterRequesterDecisionTimeAndReason()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-R", "SITE-R", "Plumbing");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-R");
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Lovelace" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Rejected",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2, 16, 40, 0),
DecidedByUserId = "user-7",
DecisionNote = "Outside this work order's scope",
RequestedByVendorName = "Gateway",
RequiredTier = 1,
RequestedNTE = 250m,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None);
var row = result.Items.Single();
row.DecidedByName.Should().Be("Ada Lovelace");
row.RequestedByName.Should().Be("Gateway");
row.DecidedAt.Should().Be(new DateTime(2026, 3, 2, 16, 40, 0));
row.DecisionNote.Should().Be("Outside this work order's scope");
row.WorkOrderNumber.Should().Be("WO-R");
}
[Fact]
public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue()
{

View file

@ -496,92 +496,6 @@ public sealed class UpliftWorkflowTests
await act.Should().ThrowAsync<InvalidOperationException>();
}
// Raised from the work order by an internal user: createdby is set and RequestedNTE
// holds the increase. Vendor sessions have no identity user, so they never set it.
private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new()
{
DispatchId = dispatchId,
CurrentNTE = 600m,
RequestedNTE = 90m,
Status = status,
RequiredTier = 1,
VendorReason = "Extra parts",
RequestedByVendorName = "Alex Dispatcher",
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = new DateTime(2026, 3, 1),
};
[Theory]
[InlineData("Pending", "withdraw")]
[InlineData("ChangesRequested", "withdraw")]
[InlineData("Pending", "cancel")]
[InlineData("ChangesRequested", "cancel")]
public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route)
{
using var context = NewContext();
var (_, _, dispatch) = await SeedAsync(context, nte: 600m);
var workOrderRequest = WorkOrderRequest(dispatch.Id, status);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
Func<Task> act = route == "cancel"
? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None)
: () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
after.Status.Should().Be(status);
after.DecidedAt.Should().BeNull();
after.RequestedNTE.Should().Be(90m);
after.createdby.Should().Be("dispatcher-1");
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel");
}
[Fact]
public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested;
await context.SaveChangesAsync();
var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Withdrawn);
context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn);
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested);
}
[Fact]
public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None);
var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.UpliftRequests.Should().HaveCount(2);
detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue();
detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse();
}
[Fact]
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
{

View file

@ -54,11 +54,6 @@ public sealed class VendorPortalDocumentTests : IDisposable
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
It.IsAny<CancellationToken>()))
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());
@ -368,278 +363,6 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public void UploadCompletionDocument_AdvertisesContractRequestLimit()
{
var attribute = Assert.Single(
typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.UploadCompletionDocument))!
.CustomAttributes,
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
bytes.Value.Should().Be(110_000_000L);
}
private static byte[] MediaBytes(int size, params byte[] header)
{
var bytes = new byte[size];
header.AsSpan().CopyTo(bytes);
return bytes;
}
private static byte[] FtypVideoBytes(int size) => MediaBytes(
size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p',
(byte)'i', (byte)'s', (byte)'o', (byte)'m');
[Fact]
public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4"));
result.Should().BeOfType<OkObjectResult>();
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ContentType.Should().Be("video/mp4");
document.SizeBytes.Should().Be(60_000_000L);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", ""));
result.Should().BeOfType<OkObjectResult>();
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime");
}
[Fact]
public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic"));
result.Should().BeOfType<OkObjectResult>();
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg");
}
[Theory]
// Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must
// follow the validated type, not the name, or the document/photo caps are bypassed.
[InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })]
[InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })]
public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap(
string fileName,
string contentType,
int size,
byte[] header)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(size, header), fileName, contentType));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
private static byte[] PhoneVideoBytes(uint durationSeconds)
{
static byte[] Box(string type, byte[] body)
{
var box = new byte[8 + body.Length];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
body.CopyTo(box, 8);
return box;
}
var mvhd = new byte[20];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600);
return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
.Concat(Box("mdat", new byte[4096]))
.Concat(Box("moov", Box("mvhd", mvhd)))
.ToArray();
}
[Theory]
[InlineData(95u, false)]
[InlineData(89u, true)]
public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime"));
if (accepted)
{
result.Should().BeOfType<OkObjectResult>();
}
else
{
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
}
[Fact]
public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
ContentType = "video/mp4",
StoredFileName = "v1.mp4",
Version = 1,
Purpose = "Completion"
});
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(2);
}
[Fact]
public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
ContentType = "video/mp4",
StoredFileName = "v1.mp4",
Version = 1,
Purpose = "Completion"
});
await context.SaveChangesAsync();
var service = NewService(context);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO
{
MaxPhotos = 10,
MaxVideos = 3,
Photos = 1,
Videos = 3,
CompletionPhotos = 1,
CompletionVideos = 2,
});
}
[Fact]
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
{

View file

@ -1,68 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// The SLA clock and every "created"/"modified" display read these stamps as UTC, so the data layer
/// must never write local server time into them.
/// </summary>
public class WorkOrderDataServiceTimestampTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrder NewWorkOrder() =>
new() { InternalWONumber = "WO-1", WorkerOrderTitle = "Leak", LocationId = 100 };
[Fact]
public async Task AddAsync_KeepsTheCreationTimeTheCallerSet()
{
await using var context = NewContext();
var createdAt = new DateTime(2026, 9, 25, 14, 0, 0, DateTimeKind.Utc);
var workOrder = NewWorkOrder();
workOrder.CreatedDate = createdAt;
var saved = await new WorkOrderDataService(context).AddAsync(workOrder);
saved.CreatedDate.Should().Be(createdAt);
saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc);
(await context.workOrders.SingleAsync()).CreatedDate.Should().Be(createdAt);
}
[Fact]
public async Task AddAsync_StampsUtcWhenTheCallerSetNoCreationTime()
{
await using var context = NewContext();
var before = DateTime.UtcNow;
var saved = await new WorkOrderDataService(context).AddAsync(NewWorkOrder());
saved.CreatedDate.Should().NotBeNull();
saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc);
saved.CreatedDate.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow);
}
[Fact]
public async Task UpdateAsync_StampsTheModificationTimeInUtc()
{
await using var context = NewContext();
var service = new WorkOrderDataService(context);
var saved = await service.AddAsync(NewWorkOrder());
var before = DateTime.UtcNow;
await service.UpdateAsync(saved);
saved.LastModificationTime.Should().NotBeNull();
saved.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
saved.LastModificationTime.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow);
}
}

View file

@ -1,217 +0,0 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope,
/// whatever other filters the board sends alongside.
/// </summary>
public class WorkOrderIdsFilterTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
=> new(
new WorkOrderAdvancedSearchDataService(ctx),
new WorkOrderAccountResolver(new AccountDataService(ctx), new LocationDataService(ctx)));
private static ClaimsPrincipal AccountUser(int accountId)
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
private static WorkOrder Wo(
int id,
DateTime? scheduled,
int accountId = 1,
string? assignTo = "disp-1",
LifecycleStatus? status = LifecycleStatus.Scheduled,
bool? deleted = null,
bool template = false)
=> new()
{
Id = id,
AccountId = accountId,
InternalWONumber = $"3000000{id:0000}",
AssignTo = assignTo,
ScheduledDate = scheduled,
LifecycleStatus = status,
IsDeleted = deleted,
istemplate = template
};
private static void Seed(ApplicationDbContext ctx)
{
ctx.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 22)),
Wo(2, null), // no schedule date
Wo(3, new DateTime(2025, 1, 6), status: LifecycleStatus.Completed),
Wo(4, new DateTime(2026, 9, 22), assignTo: "disp-2"),
Wo(5, new DateTime(2026, 9, 22)), // not requested
Wo(6, new DateTime(2026, 9, 22), accountId: 2), // another tenant
Wo(7, new DateTime(2026, 9, 22), deleted: true),
Wo(8, new DateTime(2026, 9, 22), template: true));
ctx.SaveChanges();
}
[Fact]
public async Task Ids_ReturnExactlyThoseWorkOrders_IgnoringEveryOtherFilter()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "4,1,2,3",
// Filters the board may still carry; none of them may hide a listed work order.
DatePreset = WorkOrderAdvancedSearchDatePreset.ThisWeek,
Statuses = new List<LifecycleStatus> { LifecycleStatus.Scheduled },
Dispatchers = new List<string> { "disp-1" },
Search = "no match anywhere",
PageSize = 200
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(4);
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 });
}
[Fact]
public async Task Ids_NeverWidenTenantOrBaseScope()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "1,6,7,8",
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().Equal(1);
result.TotalCount.Should().Be(1);
}
[Fact]
public async Task Ids_OnlyAnotherTenantsWorkOrders_ReturnsNothing()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "6"
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(0);
result.Items.Should().BeEmpty();
}
[Fact]
public async Task NoIds_KeepsTheExistingFilters()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2026, 9, 21),
DateTo = new DateOnly(2026, 9, 25),
Dispatchers = new List<string> { "disp-1" },
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 5 });
}
[Theory]
[InlineData("1,abc")]
[InlineData("0")]
[InlineData("-3")]
[InlineData("1,,2")]
[InlineData("1.5")]
[InlineData("99999999999")]
public async Task MalformedIds_AreABadRequest(string ids)
{
var controller = NewController();
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
badRequest.Value.Should().BeOfType<Response>().Which.Message.Should().Contain("ids");
}
[Fact]
public async Task MoreThanTheLimit_IsABadRequest()
{
var controller = NewController();
var ids = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount + 1));
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
result.Should().BeOfType<BadRequestObjectResult>()
.Which.Value.Should().BeOfType<Response>()
.Which.Message.Should().Contain("200");
}
[Fact]
public void Parse_DeduplicatesBeforeCountingAndKeepsFirstSeenOrder()
{
var withDuplicates = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount).Concat(new[] { 5, 7 }));
WorkOrderIdSet.ParseOrThrow(withDuplicates).Should().HaveCount(WorkOrderIdSet.MaxCount);
WorkOrderIdSet.ParseOrThrow(" 9, 3 ,9 ").Should().Equal(9, 3);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_AbsentOrBlank_IsNoFilter(string? ids)
{
WorkOrderIdSet.ParseOrThrow(ids).Should().BeNull();
}
private static WorkOrderBoardController NewController()
{
var search = new WorkOrderAdvancedSearchService(
Mock.Of<SeaHaven.DataServices.Interfaces.IWorkOrderAdvancedSearchDataService>(),
Mock.Of<IWorkOrderAccountResolver>());
return new WorkOrderBoardController(
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
search)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = AccountUser(1) }
}
};
}
}

View file

@ -1,6 +1,5 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
@ -32,7 +31,7 @@ public class WorkOrderMediaControllerTests
}
[Fact]
public void AddMedia_HasContractRequestLimit()
public void AddMedia_HasTwoHundredMegabyteRequestLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.Single(
@ -40,293 +39,36 @@ public class WorkOrderMediaControllerTests
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
Assert.Equal(110_000_000L, bytes.Value);
Assert.Equal(200_000_000L, bytes.Value);
}
[Fact]
public void AddMedia_HasContractMultipartBodyLimit()
public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit);
Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit);
}
[Fact]
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Videos must be 100 MB or smaller.", error.Message);
Assert.Equal("The uploaded file must not exceed 200 MB.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto()
{
// A .jpg with a foreign video type resolves to image/jpeg for validation, so it must
// also be sized as a photo, not given the 100 MB video allowance.
var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Documents must be 50 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo()
{
var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("UnsupportedMediaType", error.Code);
storage.VerifyNoOtherCalls();
}
[Fact]
public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage()
{
Assert.Equal(
"Files must be 100 MB or smaller.",
WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown));
}
private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m'];
private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0];
private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D];
/// <summary>
/// A file that reports <paramref name="length"/> bytes but only backs the 512-byte header the
/// type rules read, so oversize cases run through real signature validation cheaply.
/// </summary>
private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header)
{
var bytes = new byte[512];
header.CopyTo(bytes, 0);
return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
private static FormFile VideoFormFile(int size, string fileName, string contentType)
{
var bytes = new byte[size];
// ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV.
bytes[4] = (byte)'f';
bytes[5] = (byte)'t';
bytes[6] = (byte)'y';
bytes[7] = (byte)'p';
bytes[8] = (byte)'i';
bytes[9] = (byte)'s';
bytes[10] = (byte)'o';
bytes[11] = (byte)'m';
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
private static (Mock<IWorkOrderMediaService> Service, Mock<IFileStoragePort> Storage) SetupSuccessfulAddMedia()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service
.Setup(candidate => candidate.EnsureCanMutateMediaAsync(
1, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), null))
.Returns(Task.CompletedTask);
service
.Setup(candidate => candidate.AddMediaAsync(
1, null, "https://storage.test/stored", It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage
.Setup(candidate => candidate.SaveFileAsync(It.IsAny<IFormFile>()))
.ReturnsAsync("https://storage.test/stored");
return (service, storage);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMp4_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
}
[Fact]
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
{
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("VideoTooLong", error.Code);
Assert.Equal("Videos must be 90 seconds or shorter.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", "");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType)
{
static byte[] Box(string type, byte[] body)
{
var box = new byte[8 + body.Length];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
body.CopyTo(box, 8);
return box;
}
var mvhd = new byte[20];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000);
var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
.Concat(Box("mdat", new byte[4096]))
.Concat(Box("moov", Box("mvhd", mvhd)))
.ToArray();
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
[Fact]
public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.MOV", "");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_HeicPhoto_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var bytes = new byte[512];
bytes[4] = (byte)'f';
bytes[5] = (byte)'t';
bytes[6] = (byte)'y';
bytes[7] = (byte)'p';
bytes[8] = (byte)'h';
bytes[9] = (byte)'e';
bytes[10] = (byte)'i';
bytes[11] = (byte)'c';
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic")
{
Headers = new HeaderDictionary(),
ContentType = "image/heic"
};
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
{
@ -425,8 +167,7 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
storage.Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
@ -443,37 +184,4 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
mediaData.VerifyNoOtherCalls();
}
[Fact]
public async Task GetMediaContent_ServesHeicAsImageHeic()
{
const string url = "https://example.test/Assets/Images/photo.heic";
var mediaData = new Mock<IWorkOrderMediaDataService>(MockBehavior.Strict);
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrder { Id = 1 });
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
var service = new WorkOrderMediaService(
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "actor-1"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
},
"Test"));
var result = await service.GetMediaContentAsync(1, 10, user, "actor-1");
result.Content.Dispose();
Assert.Equal("image/heic", result.ContentType);
Assert.Equal("photo.heic", result.FileName);
}
}

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 53 routes
/// come from 52 actions (Editworkorder binds two routes).
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
/// come from 51 actions (Editworkorder binds two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
@ -60,7 +60,6 @@ public class WorkOrderRouteContractTests
"GET board/search",
"GET completion-templates",
"GET completion-templates/{id:int}",
"GET completion-templates/{id:int}/linked-work-orders",
"GET lookups/dispatchers",
"GET {id:int}",
"GET {id:int}/audit",

View file

@ -33,7 +33,16 @@ namespace Api.SeaHavenIndustries.Controllers
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
if (result != null)
{
return Ok(LoginPayload.From(result));
return Ok(new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
});
}
return Unauthorized();
@ -46,33 +55,20 @@ namespace Api.SeaHavenIndustries.Controllers
}
[Authorize]
[Route("ChangePassword")]
[HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
{
// [Compare] already rejects this during model validation; the check here keeps the
// unconfirmed password from ever being set if that validation is bypassed.
if (!string.Equals(usermodel.Newpassword, usermodel.Confirmpassword, StringComparison.Ordinal))
return BadRequest(new Response { Status = "Password confirmation does not match", Message = "Passwords don't match" });
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken);
return result.Status switch
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
if (succeeded)
{
ChangePasswordStatus.Succeeded =>
Ok(new Response { Status = "Success ", Message = "Password successfully changed" }),
ChangePasswordStatus.PasswordRejected =>
BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }),
ChangePasswordStatus.Failed =>
BadRequest(new Response { Status = "Password not changed", Message = "Your password could not be changed. Try again." }),
_ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" })
};
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
}
else
return BadRequest(new Response { Status = "Old Password is incorrect" });
}
private const string PasswordRequirementsMessage =
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.";
[HttpPost]
[Route("UpdateProfile")]
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)

View file

@ -33,7 +33,6 @@ namespace Api.SeaHavenIndustries.Controllers
scheduledTomorrow = stats.ScheduledTomorrow,
pendingUplifts = stats.PendingUplifts,
avetaPending = stats.AvetaPending,
unassigned = stats.Unassigned,
breakdown = stats.Breakdown,
dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount,

View file

@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -106,9 +105,6 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = location.Email,
location.Status,
location.AccountId,
ClientName = location.AccountName,
location.AccountName,
location.Notes,
Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
};
@ -123,10 +119,6 @@ namespace Api.SeaHavenIndustries.Controllers
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
}
catch (SiteCodeConflictException)
{
return SiteCodeConflict();
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
@ -150,10 +142,6 @@ namespace Api.SeaHavenIndustries.Controllers
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
}
catch (SiteCodeConflictException)
{
return SiteCodeConflict();
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
@ -173,73 +161,17 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPatch("{id}/contact-info")]
public async Task<IActionResult> UpdateSiteContactInfo(int id, [FromBody] SiteContactInfoInput_DTO model, CancellationToken cancellationToken)
{
try
{
await _locationService.UpdateSiteContactInfoAsync(
id,
new SiteContactInfoRequestDTO { Contacts = MapSiteContacts(model.Contacts), Notes = model.Notes },
User,
cancellationToken);
return Ok(new DataResponse { Message = "Site Updated Successfully", Status = "200" });
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to edit this site." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Location not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("{id}/open-work-orders")]
public async Task<IActionResult> GetOpenWorkOrders(int id, CancellationToken cancellationToken)
{
try
{
var result = await _locationService.GetOpenWorkOrdersAsync(id, User, cancellationToken);
if (result == null)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
return Ok(result);
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to view this site." });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteLocation(int id, CancellationToken cancellationToken)
{
try
{
var found = await _locationService.DeleteLocationByIdAsync(id, User, cancellationToken);
var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken);
if (!found)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" });
}
catch (SiteForbiddenException forbidden)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = forbidden.Message });
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to delete this site." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Location not found" });
@ -256,14 +188,6 @@ namespace Api.SeaHavenIndustries.Controllers
return await DeleteLocation(id, cancellationToken);
}
private ObjectResult SiteCodeConflict() =>
Conflict(new
{
Status = "Conflict",
Message = SiteCodeConflictException.PublicMessage,
Code = SiteCodeConflictException.ErrorCode
});
private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model)
{
return new LocationCreateRequestDTO
@ -278,7 +202,6 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId(),
Notes = model.Notes,
Contacts = MapSiteContacts(model.Contacts)
};
}
@ -297,7 +220,6 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId(),
Notes = model.Notes,
Contacts = MapSiteContacts(model.Contacts)
};
}

View file

@ -11,50 +11,10 @@ namespace Api.SeaHavenIndustries.Controllers
public class NotificationsController : ControllerBase
{
private readonly INotificationFeedService _feedService;
private readonly ISlaBreachAcknowledgementService _slaAcknowledgement;
public NotificationsController(
INotificationFeedService feedService,
ISlaBreachAcknowledgementService slaAcknowledgement)
public NotificationsController(INotificationFeedService feedService)
{
_feedService = feedService;
_slaAcknowledgement = slaAcknowledgement;
}
/// <summary>
/// Acknowledges the missed response deadline of one work order in the caller's Notification Center.
/// 204 when recorded or already recorded; 404 when the caller's feed does not cover the work order;
/// 409 when its deadline has not been missed.
/// </summary>
[HttpPost("sla/{workOrderId:int}/acknowledge")]
public async Task<IActionResult> AcknowledgeSlaBreach(int workOrderId, CancellationToken cancellationToken)
{
try
{
var outcome = await _slaAcknowledgement.AcknowledgeAsync(User, workOrderId, cancellationToken);
return outcome switch
{
SlaBreachAcknowledgementOutcome.NotFound => NotFound(new
{
code = "NotFound",
message = "Work order not found."
}),
SlaBreachAcknowledgementOutcome.NotBreached => Conflict(new
{
code = "NotBreached",
message = "This work order has not missed its response deadline."
}),
_ => NoContent()
};
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
}
[HttpGet]

View file

@ -11,39 +11,10 @@ namespace Api.SeaHavenIndustries.Controllers;
public sealed class TeamMemberController : ControllerBase
{
private readonly ITeamMemberService _teamMemberService;
private readonly ITeamPermissionService _permissionService;
private readonly ITeamMemberInviteService _inviteService;
public TeamMemberController(
ITeamMemberService teamMemberService,
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
public TeamMemberController(ITeamMemberService teamMemberService)
{
_teamMemberService = teamMemberService;
_permissionService = permissionService;
_inviteService = inviteService;
}
[HttpGet("me/permissions")]
public async Task<IActionResult> GetMyPermissions(CancellationToken cancellationToken)
{
var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : Unauthorized();
}
[HttpPost("{userId}/invite")]
public async Task<IActionResult> ResendInvite(string userId, CancellationToken cancellationToken)
{
var outcome = await _inviteService.ResendAsync(userId, User, cancellationToken);
if (outcome.Success)
return Ok(new { message = "Invite sent" });
return outcome.Error switch
{
"Forbidden" => Forbid(),
"Team member not found." => NotFound(new { message = outcome.Error }),
_ => BadRequest(new { message = outcome.Error })
};
}
[HttpPost]

View file

@ -1,95 +0,0 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers;
/// <summary>
/// Anonymous invite registration. The invite token travels only in request bodies,
/// and every failure maps to a fixed public message.
/// </summary>
[AllowAnonymous]
[ApiController]
[Route("api/team-member-invites")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[TypeFilter(typeof(InviteRegistrationExceptionFilter))]
public sealed class TeamMemberInviteController : ControllerBase
{
public const string InvalidInviteMessage =
"This invite link is invalid or has expired. Ask your admin to send a new invite.";
private readonly ITeamMemberRegistrationService _registrationService;
public TeamMemberInviteController(ITeamMemberRegistrationService registrationService)
{
_registrationService = registrationService;
}
[HttpPost("resolve")]
public async Task<IActionResult> Resolve(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.ResolveAsync(request.Token, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok ? Ok(outcome.Details) : Failure(outcome);
}
[HttpPost("send-code")]
public async Task<IActionResult> SendCode(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.SendCodeAsync(request.Token, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok
? Ok(new { message = "Code sent" })
: Failure(outcome);
}
[HttpPost("verify-code")]
public async Task<IActionResult> VerifyCode(VerifyTeamMemberInviteCodeRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.VerifyCodeAsync(request.Token, request.Code, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok
? Ok(new { message = "Email confirmed" })
: Failure(outcome);
}
[HttpPost("complete")]
public async Task<IActionResult> Complete(CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.CompleteAsync(request, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok && outcome.Session is not null
? Ok(LoginPayload.From(outcome.Session))
: Failure(outcome);
}
private IActionResult Failure(TeamMemberRegistrationOutcomeDTO outcome)
{
var (statusCode, code, message) = outcome.Status switch
{
TeamMemberRegistrationStatus.CodeIncorrect =>
(StatusCodes.Status400BadRequest, "code_incorrect", "Incorrect code — check your email and try again"),
TeamMemberRegistrationStatus.CodeExpired =>
(StatusCodes.Status400BadRequest, "code_expired", "This code has expired — request a new code"),
TeamMemberRegistrationStatus.CodeLocked =>
(StatusCodes.Status400BadRequest, "code_locked", "Too many incorrect attempts — request a new code"),
TeamMemberRegistrationStatus.ResendTooSoon =>
(StatusCodes.Status429TooManyRequests, "resend_too_soon", "Please wait a moment before requesting another code"),
TeamMemberRegistrationStatus.ResendLimitReached =>
(StatusCodes.Status429TooManyRequests, "resend_limit_reached", "Too many codes were requested. Ask your admin to send a new invite."),
TeamMemberRegistrationStatus.CodeDeliveryFailed =>
(StatusCodes.Status503ServiceUnavailable, "code_delivery_failed", "We couldn't send the code. Try again in a minute."),
TeamMemberRegistrationStatus.EmailNotConfirmed =>
(StatusCodes.Status400BadRequest, "email_not_confirmed", "Confirm your email before finishing registration"),
TeamMemberRegistrationStatus.PasswordRejected =>
(StatusCodes.Status400BadRequest, "password_rejected", "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."),
TeamMemberRegistrationStatus.InvalidPhone =>
(StatusCodes.Status400BadRequest, "invalid_phone", "Enter a valid phone number"),
_ => (StatusCodes.Status400BadRequest, "invalid_invite", InvalidInviteMessage)
};
if (outcome.RetryAfterSeconds is int retryAfter)
Response.Headers.RetryAfter = retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture);
return StatusCode(statusCode, new { code, message, retryAfterSeconds = outcome.RetryAfterSeconds });
}
}

View file

@ -53,7 +53,6 @@ public sealed class TeamPermissionController : ControllerBase
return result.Status switch
{
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
TeamPermissionResultStatus.Unauthorized => new UnauthorizedResult(),
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
new Response { Status = "Error", Message = "User not found." }),

View file

@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -300,7 +299,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("dispatches/{id:int}/completion-documents")]
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)]
[RequestSizeLimit(10_000_000)]
public async Task<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,

View file

@ -18,111 +18,83 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderCompletionController : Controller
{
private readonly IWorkOrderCompletionService _workOrderCompletionService;
private readonly ICompletionDocTemplateService _completionDocTemplateService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderCompletionController(
IWorkOrderCompletionService workOrderCompletionService,
ICompletionDocTemplateService completionDocTemplateService,
IFileStoragePort fileStorage)
{
_workOrderCompletionService = workOrderCompletionService;
_completionDocTemplateService = completionDocTemplateService;
_fileStorage = fileStorage;
}
[HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? search = null,
[FromQuery] string? serviceKey = null,
[FromQuery] WorkOrderType? workOrderType = null,
CancellationToken cancellationToken = default)
[FromQuery] WorkOrderType? workOrderType = null)
{
var templates = await _completionDocTemplateService.ListAsync(search, serviceKey, workOrderType, cancellationToken);
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
return Ok(templates);
}
[HttpGet("completion-templates/{id:int}")]
public async Task<IActionResult> GetCompletionTemplate(int id, CancellationToken cancellationToken)
public async Task<IActionResult> GetCompletionTemplate(int id)
{
var template = await _completionDocTemplateService.GetAsync(id, cancellationToken);
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found." });
return Ok(template);
}
[HttpGet("completion-templates/{id:int}/linked-work-orders")]
public async Task<IActionResult> GetCompletionTemplateLinkedWorkOrders(int id, CancellationToken cancellationToken)
{
try
{
var linked = await _completionDocTemplateService.GetLinkedWorkOrdersAsync(User, id, cancellationToken);
return Ok(linked);
}
catch (WorkOrderBoardValidationException ex)
{
return MapTemplateError(ex);
}
}
[Authorize(Roles = "Admin")]
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate(
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
{
try
{
var created = await _completionDocTemplateService.CreateAsync(User, request, cancellationToken);
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
return Ok(created);
}
catch (WorkOrderBoardValidationException ex)
{
return MapTemplateError(ex);
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[Authorize(Roles = "Admin")]
[HttpPut("completion-templates/{id:int}")]
public async Task<IActionResult> UpdateCompletionTemplate(
int id,
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
{
try
{
var updated = await _completionDocTemplateService.UpdateAsync(User, id, request, cancellationToken);
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
return Ok(updated);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return MapTemplateError(ex);
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[Authorize(Roles = "Admin")]
[HttpDelete("completion-templates/{id:int}")]
public async Task<IActionResult> DeleteCompletionTemplate(int id, CancellationToken cancellationToken)
public async Task<IActionResult> DeleteCompletionTemplate(int id)
{
try
{
await _completionDocTemplateService.DeleteAsync(User, id, cancellationToken);
await _workOrderCompletionService.DeleteTemplateAsync(id);
return NoContent();
}
catch (WorkOrderBoardValidationException ex)
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return MapTemplateError(ex);
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
private IActionResult MapTemplateError(WorkOrderBoardValidationException ex)
{
var body = new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message };
return ex.Code switch
{
"Forbidden" => StatusCode(StatusCodes.Status403Forbidden, body),
"NotFound" => NotFound(body),
_ => UnprocessableEntity(body)
};
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(50_000_000)]
public async Task<IActionResult> UploadCompletionDoc(

View file

@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/workorders")]
public class WorkOrderMediaController : Controller
{
public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes;
public const long MaxUploadBytes = 200_000_000;
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IFileStoragePort _fileStorage;
@ -88,28 +88,14 @@ namespace Api.SeaHavenIndustries.Controllers
string? fileUrl = null;
try
{
// Type first, so an unsupported file always reports UnsupportedMediaType instead
// of being sized under a kind it does not have.
if (file.Length > MaxUploadBytes)
{
throw new WorkOrderBoardValidationException(
"FileTooLarge",
"The uploaded file must not exceed 200 MB.");
}
WorkOrderMediaFileRules.EnsureAllowed(file, category);
// Media contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
// Classify by the same resolved type EnsureAllowed validates against.
var sizeMessage = WorkOrderMediaContract.ValidateSize(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length);
if (sizeMessage != null)
{
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
}
if (WorkOrderMediaContract.ResolveKind(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
== WorkOrderMediaContract.UploadKind.Video)
{
using var content = file.OpenReadStream();
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
if (durationMessage != null)
throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage);
}
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);

View file

@ -16,7 +16,6 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public string? Notes { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -11,13 +11,6 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Phone { get; set; }
}
/// <summary>Contacts and notes edited from the work-order Site dialog.</summary>
public class SiteContactInfoInput_DTO
{
public List<SiteContactInput_DTO>? Contacts { get; set; }
public string? Notes { get; set; }
}
public class Location_DTO
{
public string? Title { get; set; }
@ -31,7 +24,6 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public string? Notes { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -1,24 +0,0 @@
using SeaHaven.Services.DTOs;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>The session payload the web app stores after sign-in.</summary>
public static class LoginPayload
{
public static object From(LoginResultDTO result)
{
ArgumentNullException.ThrowIfNull(result);
return new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
};
}
}
}

View file

@ -1,38 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
namespace Api.SeaHavenIndustries.Filters
{
/// <summary>
/// Anonymous invite endpoints never echo an exception: controller-scoped exception
/// filters run before the global ones, so no message, stack or SQL reaches the caller.
/// Cancellation is left to the host.
/// </summary>
public sealed class InviteRegistrationExceptionFilter : IExceptionFilter
{
public const string Message = "Something went wrong. Try again in a minute.";
private readonly ILogger<InviteRegistrationExceptionFilter> _logger;
public InviteRegistrationExceptionFilter(ILogger<InviteRegistrationExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
if (context.Exception is OperationCanceledException)
return;
_logger.LogError(context.Exception, "Invite registration request failed.");
context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null })
{
StatusCode = StatusCodes.Status500InternalServerError
};
context.ExceptionHandled = true;
}
}
}

View file

@ -9,37 +9,14 @@ namespace Api.SeaHavenIndustries.Helper
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
private readonly ILogger<SendMessage> _logger;
//string keysapi = "";
public SendMessage(IConfiguration configuration, ILogger<SendMessage> logger)
public SendMessage(IConfiguration configuration)
{
_configuration = configuration;
_logger = logger;
//keysapi = _configuration.GetValue<string>("SendGrid:ApiKey");
}
protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey);
/// <summary>
/// SendGrid reports a rejected message through the status code, not an exception.
/// Only the status is logged: never the recipient, subject or response body.
/// </summary>
private bool Delivered(Response response)
{
if (response.IsSuccessStatusCode)
return true;
_logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode);
return false;
}
private bool Failed(Exception ex)
{
_logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name);
return false;
}
public async Task<bool> SendEMail(string emailTo, string subject, string body)
{
try
@ -64,7 +41,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = CreateClient(apiKey);
var client = new SendGridClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -74,11 +51,12 @@ namespace Api.SeaHavenIndustries.Helper
var htmlContent = body;
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
var response = await client.SendEmailAsync(msg);
return Delivered(response);
var dd = response.Body.ReadAsStringAsync();
return true;
}
catch (Exception ex)
{
return Failed(ex);
return false;
}
}
public async Task<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
@ -91,7 +69,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = CreateClient(apiKey);
var client = new SendGridClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -114,11 +92,13 @@ namespace Api.SeaHavenIndustries.Helper
msg.Attachments = new List<SendGrid.Helpers.Mail.Attachment> { attachment };
var response = await client.SendEmailAsync(msg);
return Delivered(response);
var dd = response.Body.ReadAsStringAsync();
return true;
}
catch (Exception ex)
{
return Failed(ex);
return false;
}
}
public async Task<bool> SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
@ -126,7 +106,7 @@ namespace Api.SeaHavenIndustries.Helper
try
{
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
var client = CreateClient(apiKey);
var client = new SendGridClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries");
var to = new EmailAddress(emailTo, "");
@ -136,11 +116,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.SetReplyTo(new EmailAddress(replyTo));
var response = await client.SendEmailAsync(msg);
return Delivered(response);
return true;
}
catch (Exception ex)
{
return Failed(ex);
return false;
}
}
}

View file

@ -1,24 +0,0 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
namespace Api.SeaHavenIndustries.Infrastructure
{
public static class IdentityRegistration
{
/// <summary>
/// Registers ASP.NET Identity for the API with the shared password policy.
/// Program.cs and the behavior tests both compose Identity through this
/// method so the rule under test is the rule that runs.
/// </summary>
public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services)
{
return services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
IdentityPasswordPolicy.Apply(options.Password);
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
}
}
}

View file

@ -1,6 +1,5 @@
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Observability;
using Api.SeaHavenIndustries.Options;
@ -44,7 +43,12 @@ ConfigurationManager configuration = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddSeaHavenIdentity();
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddControllers(options =>
{

View file

@ -54,23 +54,6 @@ namespace Data.SeaHavenIndustries
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<CompletionDocTemplate>()
.Property(t => t.ExtraSafetyNote)
.HasMaxLength(CompletionDocTemplate.ExtraSafetyNoteMaxLength);
builder.Entity<CompletionDocTemplateProcedure>(entity =>
{
entity.Property(p => p.Name)
.HasMaxLength(CompletionDocTemplateProcedure.NameMaxLength);
entity.HasOne(p => p.CompletionDocTemplate)
.WithMany(t => t.Procedures)
.HasForeignKey(p => p.CompletionDocTemplateId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => new { p.CompletionDocTemplateId, p.SortOrder });
});
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
@ -328,21 +311,6 @@ namespace Data.SeaHavenIndustries
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
});
builder.Entity<TeamMemberInvite>(entity =>
{
entity.HasIndex(invite => invite.TokenHash)
.IsUnique()
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
entity.HasIndex(invite => invite.UserId)
.HasDatabaseName("IX_TeamMemberInvites_UserId");
entity.HasOne(invite => invite.User)
.WithMany()
.HasForeignKey(invite => invite.UserId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<UserServiceArea>(entity =>
{
entity.HasKey(area => new { area.UserId, area.Area });
@ -376,7 +344,6 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<CompletionDocTemplateProcedure> CompletionDocTemplateProcedures { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
@ -421,7 +388,6 @@ namespace Data.SeaHavenIndustries
public DbSet<Region> Regions { get; set; }
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
public DbSet<TeamMemberInvite> TeamMemberInvites { get; set; }
public override int SaveChanges()
{

View file

@ -1,47 +0,0 @@
using Microsoft.AspNetCore.Identity;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// The single password rule for every surface that sets a password: at least
/// six characters with one uppercase letter, one number, and one special
/// character. Lowercase letters are deliberately not required so the server
/// accepts exactly what the four-item checklist in the web app marks as met.
/// </summary>
public static class IdentityPasswordPolicy
{
public const int MinimumLength = 6;
public static void Apply(PasswordOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.RequiredLength = MinimumLength;
options.RequireUppercase = true;
options.RequireDigit = true;
options.RequireNonAlphanumeric = true;
options.RequireLowercase = false;
options.RequiredUniqueChars = 1;
}
private static readonly HashSet<string> PolicyErrorCodes = new(StringComparer.Ordinal)
{
nameof(IdentityErrorDescriber.PasswordTooShort),
nameof(IdentityErrorDescriber.PasswordRequiresUpper),
nameof(IdentityErrorDescriber.PasswordRequiresLower),
nameof(IdentityErrorDescriber.PasswordRequiresDigit),
nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric),
nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars)
};
/// <summary>
/// True when Identity refused the password itself. Other failures, such as a
/// concurrency conflict, must not be reported to the user as a weak password.
/// </summary>
public static bool IsPolicyRejection(IdentityResult result)
{
ArgumentNullException.ThrowIfNull(result);
return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code));
}
}
}

View file

@ -35,8 +35,6 @@ namespace Data.SeaHavenIndustries.Enums
Inspection = 5,
Reactive = 6,
AddOn = 7,
/// <summary>Client never responded after the due date passed. Distinct from the derived past-due overlay.</summary>
Overdue = 8,
Other = 99
}

View file

@ -1,59 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class CompletionDocTemplateProcedures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.CreateTable(
name: "CompletionDocTemplateProcedures",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CompletionDocTemplateProcedures", x => x.Id);
table.ForeignKey(
name: "FK_CompletionDocTemplateProcedures_CompletionDocTemplates_CompletionDocTemplateId",
column: x => x.CompletionDocTemplateId,
principalTable: "CompletionDocTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CompletionDocTemplateProcedures_CompletionDocTemplateId_SortOrder",
table: "CompletionDocTemplateProcedures",
columns: new[] { "CompletionDocTemplateId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompletionDocTemplateProcedures");
migrationBuilder.DropColumn(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddLocationNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Locations",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Notes",
table: "Locations");
}
}
}

View file

@ -1,64 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddTeamMemberInvites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeamMemberInvites",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
TokenHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UsedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RevokedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
CodeSalt = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
CodeExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeFailedAttempts = table.Column<int>(type: "int", nullable: false),
CodeSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeSendCount = table.Column<int>(type: "int", nullable: false),
EmailConfirmedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMemberInvites", x => x.Id);
table.ForeignKey(
name: "FK_TeamMemberInvites_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_TeamMemberInvites_TokenHash",
table: "TeamMemberInvites",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeamMemberInvites_UserId",
table: "TeamMemberInvites",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeamMemberInvites");
}
}
}

View file

@ -523,10 +523,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("ExtraSafetyNote")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@ -564,36 +560,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("CompletionDocTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompletionDocTemplateId")
.HasColumnType("int");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CompletionDocTemplateId", "SortOrder");
b.ToTable("CompletionDocTemplateProcedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.Property<int>("Id")
@ -1616,9 +1582,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
@ -2104,71 +2067,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("TaskListTemplateItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CodeExpiresAt")
.HasColumnType("datetime2");
b.Property<int>("CodeFailedAttempts")
.HasColumnType("int");
b.Property<string>("CodeHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CodeSalt")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("CodeSendCount")
.HasColumnType("int");
b.Property<DateTime?>("CodeSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime?>("UsedAt")
.HasColumnType("datetime2");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
b.HasIndex("UserId")
.HasDatabaseName("IX_TeamMemberInvites_UserId");
b.ToTable("TeamMemberInvites");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
{
b.Property<int>("Id")
@ -3583,17 +3481,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
.WithMany("Procedures")
.HasForeignKey("CompletionDocTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CompletionDocTemplate");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact")
@ -3898,17 +3785,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Template");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
@ -4232,11 +4108,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrderCategories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Navigation("Procedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
{
b.Navigation("WorkOrderContacts");

View file

@ -4,32 +4,10 @@ namespace Data.SeaHavenIndustries
{
public class CompletionDocTemplate : FullAuditEntity
{
public const int ExtraSafetyNoteMaxLength = 2000;
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
// At most one template-specific note, printed after the standard
// safety bullets every generated document carries.
public string? ExtraSafetyNote { get; set; }
public virtual ICollection<CompletionDocTemplateProcedure> Procedures { get; set; } = new List<CompletionDocTemplateProcedure>();
}
// Ordered procedure step of a completion document template. SortOrder is the
// position the author gave it; documents print procedures in that order.
public class CompletionDocTemplateProcedure
{
public const int NameMaxLength = 200;
public int Id { get; set; }
public int CompletionDocTemplateId { get; set; }
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
public int SortOrder { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
}
}

View file

@ -27,9 +27,6 @@ namespace Data.SeaHavenIndustries
public string? ExternalSource { get; set; }
public string? ExternalLocationId { get; set; }
/// <summary>Free-text site notes, edited from the work-order Site dialog.</summary>
public string? Notes { get; set; }
// Navigation Properties
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? workOrders { get; set; }

View file

@ -1,44 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// A single-use registration invite for one pending team member. Only hashes of
/// the invite token and of the email confirmation code are stored.
/// </summary>
public class TeamMemberInvite
{
public int Id { get; set; }
[Required]
[MaxLength(450)]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public virtual ApplicationUser? User { get; set; }
/// <summary>Lowercase hex SHA-256 of the raw invite token.</summary>
[Required]
[MaxLength(64)]
public string TokenHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? UsedAt { get; set; }
public DateTime? RevokedAt { get; set; }
/// <summary>Lowercase hex SHA-256 of the salt followed by the confirmation code.</summary>
[MaxLength(64)]
public string? CodeHash { get; set; }
[MaxLength(32)]
public string? CodeSalt { get; set; }
public DateTime? CodeExpiresAt { get; set; }
public int CodeFailedAttempts { get; set; }
public DateTime? CodeSentAt { get; set; }
public int CodeSendCount { get; set; }
public DateTime? EmailConfirmedAt { get; set; }
}
}

View file

@ -45,9 +45,6 @@ namespace Data.SeaHavenIndustries
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
// True when the vendor raised the request in the portal; false when it was raised
// from the work order. Only vendor-raised requests are revisable or withdrawable.
public bool RaisedByVendor { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }

View file

@ -21,12 +21,6 @@ Both run in `us-east-1` on the .NET 8 Amazon Linux 2023 platform. Terraform in
`terraform/live/` owns the environments; GitHub Actions owns the application
versions. There is no production environment yet.
Stored created, modified and deletion times are UTC: the API stamps them with
`DateTime.UtcNow`, whatever the host's time zone. The API has only ever run on
Linux Elastic Beanstalk hosts left at their UTC default (nothing in Terraform,
`.ebextensions` or `.platform` sets a time zone), so rows written before the
switch from `DateTime.Now` are already UTC and need no backfill.
## Architecture
```text

View file

@ -1,36 +0,0 @@
namespace SeaHaven.DataServices.Dto
{
public sealed class TeamMemberInviteData
{
public required int Id { get; init; }
public required string UserId { get; init; }
public DateTime ExpiresAt { get; init; }
public DateTime? UsedAt { get; init; }
public DateTime? RevokedAt { get; init; }
public string? CodeHash { get; init; }
public string? CodeSalt { get; init; }
public DateTime? CodeExpiresAt { get; init; }
public int CodeFailedAttempts { get; init; }
public DateTime? CodeSentAt { get; init; }
public int CodeSendCount { get; init; }
public DateTime? EmailConfirmedAt { get; init; }
}
/// <summary>
/// Replaces the confirmation code on an open invite when the resend cooldown has
/// elapsed and the send limit has not been reached.
/// </summary>
public sealed class StartTeamMemberInviteCodeCommand
{
public required int InviteId { get; init; }
public required string CodeHash { get; init; }
public required string CodeSalt { get; init; }
public required DateTime Now { get; init; }
public required DateTime CodeExpiresAt { get; init; }
/// <summary>A code may be replaced only when the previous one was sent at or before this instant.</summary>
public required DateTime LastSentNoLaterThan { get; init; }
public required int MaxSends { get; init; }
}
}

View file

@ -38,7 +38,6 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNameSnapshot,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
SiteNotes = w.Locations != null ? w.Locations.Notes : null,
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
@ -135,7 +134,7 @@ namespace SeaHaven.DataServices.Helpers
var pocPhone = primaryFrozenPoc?.Phone
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
var pocNotes = frozenPoc?.Notes
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes, w.SiteNotes);
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);

View file

@ -5,14 +5,6 @@ namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardQueryFilters
{
/// <summary>
/// The window the Work Orders board searches when no date range is selected
/// (client ADVANCED_SEARCH_ALL_WEEKS_FROM/TO), used with includeDateless.
/// Counts that drill into that list use it too, so both see the same rows.
/// </summary>
public static readonly DateOnly AllWeeksFrom = new(2000, 1, 1);
public static readonly DateOnly AllWeeksTo = new(2099, 12, 31);
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
@ -58,9 +50,9 @@ namespace SeaHaven.DataServices.Helpers
}
/// <summary>
/// Filters by stored <paramref name="types"/> (including <see cref="WorkOrderType.Overdue"/> and
/// <see cref="WorkOrderType.Other"/>) and/or past-due rows when <paramref name="overdue"/> is true.
/// Past due is a status overlay, so types and overdue are combined with AND when both are present.
/// Filters by real <paramref name="types"/> (including <see cref="WorkOrderType.Other"/>)
/// and/or past-due rows when <paramref name="overdue"/> is true.
/// Types and overdue are combined with OR when both are present.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query,
@ -94,8 +86,8 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
&& ((w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn)));
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn));
}
return query.Where(w =>
@ -163,44 +155,6 @@ namespace SeaHaven.DataServices.Helpers
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
}
/// <summary>
/// Lower-cased legacy status text and the lifecycle status it stands for:
/// the Phase0 backfill rules, kept equal to LifecycleStatusMapper.FromLegacyStatus.
/// </summary>
public static readonly IReadOnlyDictionary<string, LifecycleStatus> LegacyStatusValues =
new Dictionary<string, LifecycleStatus>(StringComparer.Ordinal)
{
["open"] = LifecycleStatus.Incomplete,
["incomplete"] = LifecycleStatus.Incomplete,
["pending"] = LifecycleStatus.Pending,
["onhold"] = LifecycleStatus.Pending,
["on hold"] = LifecycleStatus.Pending,
["scheduled"] = LifecycleStatus.Scheduled,
["en route"] = LifecycleStatus.EnRoute,
["enroute"] = LifecycleStatus.EnRoute,
["on site"] = LifecycleStatus.OnSite,
["onsite"] = LifecycleStatus.OnSite,
["inprogress"] = LifecycleStatus.InProgress,
["in progress"] = LifecycleStatus.InProgress,
["completed"] = LifecycleStatus.Completed,
["complete"] = LifecycleStatus.Completed,
["done"] = LifecycleStatus.Completed,
["closed"] = LifecycleStatus.Completed,
["rescheduled"] = LifecycleStatus.Rescheduled,
["cancelled"] = LifecycleStatus.Canceled,
["canceled"] = LifecycleStatus.Canceled,
["pending quote"] = LifecycleStatus.PendingQuote,
["pendingquote"] = LifecycleStatus.PendingQuote,
["pendingapproval"] = LifecycleStatus.PendingQuote,
["pending approval"] = LifecycleStatus.PendingQuote,
};
/// <summary>
/// Keeps rows in <paramref name="statuses"/>. Rows with no LifecycleStatus
/// (legacy create paths still write only Status) match by their legacy
/// status, read as the Phase0 backfill does: known text maps through
/// <see cref="LegacyStatusValues"/>, anything else, blank included, is Incomplete.
/// </summary>
public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)
@ -208,22 +162,7 @@ namespace SeaHaven.DataServices.Helpers
if (statuses == null || statuses.Count == 0)
return query;
var legacyValues = LegacyStatusValues
.Where(entry => statuses.Contains(entry.Value))
.Select(entry => entry.Key)
.ToList();
var knownLegacyValues = LegacyStatusValues.Keys.ToList();
var includeUnknownLegacy = statuses.Contains(LifecycleStatus.Incomplete);
// Null check before Trim(): EF translates it, the in-memory provider runs it.
return query.Where(w =>
(w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value))
|| (w.LifecycleStatus == null
&& (((w.LegacyStatus ?? w.Status) != null
&& legacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower()))
|| (includeUnknownLegacy
&& ((w.LegacyStatus ?? w.Status) == null
|| !knownLegacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower()))))));
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
}
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
@ -281,26 +220,13 @@ namespace SeaHaven.DataServices.Helpers
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
/// <summary>
/// Narrows to rows whose Schedule On date falls in the range, or, for
/// week-only rows, whose Target Week overlaps it: the same date the
/// weekly board groups by (SH-391). Rows with no date never match a
/// range; they are added only when <paramref name="includeDateless"/>
/// is set, which callers send for searches with no date range selected.
/// Completed and canceled dateless rows stay out, matching
/// <see cref="ApplyUnscheduledOnlyFilter"/>.
/// </summary>
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> query,
DateOnly dateFrom,
DateOnly dateTo,
bool includeDateless = false)
DateOnly dateTo)
{
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
// A target week (stored as its Monday) overlaps the range when it
// starts by the range end and its Sunday is on or after the range start.
var earliestWeekStart = dateFrom.DayNumber >= 6 ? dateFrom.AddDays(-6) : dateFrom;
return query.Where(w =>
(w.ScheduledDate != null
@ -308,10 +234,9 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date <= toDate)
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= earliestWeekStart
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo)
|| (includeDateless
&& w.ScheduledDate == null
|| (w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled));
}

View file

@ -57,7 +57,6 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Overdue && "overdue".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))

View file

@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Accounts> AddAsync(Accounts account)
{
account.CreatedDate = DateTime.UtcNow;
account.CreatedDate = DateTime.Now;
await _context.Accounts.AddAsync(account);
await _context.SaveChangesAsync();
return account;
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Accounts account)
{
account.LastModificationTime = DateTime.UtcNow;
account.LastModificationTime = DateTime.Now;
_context.Accounts.Update(account);
await _context.SaveChangesAsync();
}

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Assets> AddAsync(Assets asset)
{
asset.CreatedDate = DateTime.UtcNow;
asset.CreatedDate = DateTime.Now;
await _context.Assets.AddAsync(asset);
await _context.SaveChangesAsync();
return asset;
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Assets asset)
{
asset.LastModificationTime = DateTime.UtcNow;
asset.LastModificationTime = DateTime.Now;
_context.Assets.Update(asset);
await _context.SaveChangesAsync();
}

View file

@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Category> AddAsync(Category category)
{
category.CreatedDate = DateTime.UtcNow;
category.CreatedDate = DateTime.Now;
await _context.Categories.AddAsync(category);
await _context.SaveChangesAsync();
return category;
@ -33,7 +33,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Category category)
{
category.LastModificationTime = DateTime.UtcNow;
category.LastModificationTime = DateTime.Now;
_context.Categories.Update(category);
await _context.SaveChangesAsync();
}

View file

@ -42,7 +42,6 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Where(n => n.IsDeleted != true)
.Select(n => new Locations
{
Id = n.Id,

View file

@ -1,17 +1,12 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
{
private static readonly LifecycleStatus[] OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
.ToArray();
private readonly ApplicationDbContext _context;
public CompletionDocTemplateDataService(ApplicationDbContext context)
@ -19,6 +14,33 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
return await query
.OrderBy(t => t.Name)
.Select(MapRow)
.ToListAsync();
}
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
{
if (!string.IsNullOrWhiteSpace(trade))
@ -48,149 +70,33 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync();
}
public async Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim().ToLower();
query = query.Where(t => t.Name.ToLower().Contains(term));
}
return await ProjectDetail(query).ToListAsync(cancellationToken);
}
public async Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true);
return await ProjectDetail(query).FirstOrDefaultAsync(cancellationToken);
}
public Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates
.Include(t => t.Procedures)
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public async Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken)
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
{
template.CreatedDate = DateTime.UtcNow;
await _context.CompletionDocTemplates.AddAsync(template, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return template.Id;
await _context.CompletionDocTemplates.AddAsync(template);
await _context.SaveChangesAsync();
return template;
}
public async Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken)
public async Task UpdateAsync(CompletionDocTemplate template)
{
if (replacementProcedures != null)
{
// Explicit removal: the context forces Restrict on every FK before the
// procedure cascade is configured, so orphan deletion is not relied on.
_context.CompletionDocTemplateProcedures.RemoveRange(template.Procedures.ToList());
foreach (var procedure in replacementProcedures)
{
procedure.CompletionDocTemplateId = template.Id;
_context.CompletionDocTemplateProcedures.Add(procedure);
}
}
template.LastModificationTime = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
_context.CompletionDocTemplates.Update(template);
await _context.SaveChangesAsync();
}
public async Task<bool> DeleteAndUnlinkServicesAsync(
int id,
string? deleterUserId,
CancellationToken cancellationToken)
public async Task<bool> DeleteAsync(int id)
{
var template = await _context.CompletionDocTemplates
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
if (template == null)
var entity = await _context.CompletionDocTemplates.FindAsync(id);
if (entity == null)
return false;
var now = DateTime.UtcNow;
template.IsDeleted = true;
template.DeletionTime = now;
template.DeleterUserId = deleterUserId;
var linkedServices = await _context.Services
.Where(s => s.CompletionDocTemplateId == id)
.ToListAsync(cancellationToken);
foreach (var service in linkedServices)
{
service.CompletionDocTemplateId = null;
service.LastModificationTime = now;
}
await _context.SaveChangesAsync(cancellationToken);
entity.IsDeleted = true;
entity.DeletionTime = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value);
var linkedServiceIds = _context.Services
.Where(s => s.CompletionDocTemplateId == templateId)
.Select(s => (int?)s.Id);
// Shared status filter so legacy rows without a LifecycleStatus are read
// through their legacy status text (a legacy "completed" row is not open).
workOrders = WorkOrderBoardQueryFilters.ApplyStatusFilter(workOrders, OpenStatuses);
return await workOrders
.Where(w => w.ServiceId != null && linkedServiceIds.Contains(w.ServiceId))
.OrderBy(w => w.Id)
.Select(w => w.Id)
.ToListAsync(cancellationToken);
}
private IQueryable<CompletionDocTemplateDetailRow> ProjectDetail(IQueryable<CompletionDocTemplate> templates)
=> from t in templates
join u in _context.Users on t.createdby equals u.Id into creators
from creator in creators.DefaultIfEmpty()
orderby t.Name, t.Id
select new CompletionDocTemplateDetailRow(
t.Id,
t.Name,
t.ServiceKey,
t.WorkOrderType,
t.TemplateUrl,
t.IsActive,
t.ExtraSafetyNote,
t.CreatedDate,
t.LastModificationTime,
creator != null ? creator.FirstName : null,
creator != null ? creator.LastName : null,
t.Procedures
.OrderBy(p => p.SortOrder)
.ThenBy(p => p.Id)
.Select(p => new CompletionDocTemplateProcedureRow(p.Id, p.SortOrder, p.Name, p.Description))
.ToList());
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}

View file

@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Contacts> AddAsync(Contacts contact)
{
contact.CreatedDate = DateTime.UtcNow;
contact.CreatedDate = DateTime.Now;
await _context.Contacts.AddAsync(contact);
await _context.SaveChangesAsync();
return contact;
@ -108,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Contacts contact)
{
contact.LastModificationTime = DateTime.UtcNow;
contact.LastModificationTime = DateTime.Now;
_context.Contacts.Update(contact);
await _context.SaveChangesAsync();
}
@ -130,15 +130,15 @@ namespace SeaHaven.DataServices.Implementation
public async Task<bool> EmailExistsAsync(string email, int? excludeId = null)
{
return await _context.Contacts.AnyAsync(c =>
c.Email == email &&
return await _context.Contacts.AnyAsync(c =>
c.Email == email &&
(excludeId == null || c.Id != excludeId));
}
public async Task<bool> PhoneExistsAsync(string phone, int? excludeId = null)
{
return await _context.Contacts.AnyAsync(c =>
c.PhoneNumber == phone &&
return await _context.Contacts.AnyAsync(c =>
c.PhoneNumber == phone &&
(excludeId == null || c.Id != excludeId));
}

View file

@ -39,41 +39,6 @@ namespace SeaHaven.DataServices.Implementation
};
}
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
.GetValues<LifecycleStatus>()
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
.ToList();
public async Task<int> CountOpenUnassignedAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken)
{
// A dispatcher-scoped Dashboard has no unassigned work by definition.
if (dispatcherId != null)
return 0;
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null);
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
// All time counts what the board lists with no range: every week plus
// undated work, so the tile and its drill-down agree.
query = dateFrom is DateOnly from && dateTo is DateOnly to
? WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to)
: WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
query,
WorkOrderBoardQueryFilters.AllWeeksFrom,
WorkOrderBoardQueryFilters.AllWeeksTo,
includeDateless: true);
return await query.CountAsync(cancellationToken);
}
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,

View file

@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Dispatch> AddAsync(Dispatch dispatch)
{
dispatch.CreatedDate = DateTime.UtcNow;
dispatch.CreatedDate = DateTime.Now;
await _context.Dispatches.AddAsync(dispatch);
await _context.SaveChangesAsync();
return dispatch;
@ -93,7 +93,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Dispatch dispatch)
{
dispatch.LastModificationTime = DateTime.UtcNow;
dispatch.LastModificationTime = DateTime.Now;
_context.Dispatches.Update(dispatch);
await _context.SaveChangesAsync();
}

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Employee> AddAsync(Employee employee)
{
employee.CreatedDate = DateTime.UtcNow;
employee.CreatedDate = DateTime.Now;
await _context.Employees.AddAsync(employee);
await _context.SaveChangesAsync();
return employee;
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Employee employee)
{
employee.LastModificationTime = DateTime.UtcNow;
employee.LastModificationTime = DateTime.Now;
_context.Employees.Update(employee);
await _context.SaveChangesAsync();
}

View file

@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<FollowUps> AddAsync(FollowUps followUp)
{
followUp.CreatedDate = DateTime.UtcNow;
followUp.CreatedDate = DateTime.Now;
await _context.FollowUps.AddAsync(followUp);
await _context.SaveChangesAsync();
return followUp;
@ -96,7 +96,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(FollowUps followUp)
{
followUp.LastModificationTime = DateTime.UtcNow;
followUp.LastModificationTime = DateTime.Now;
_context.FollowUps.Update(followUp);
await _context.SaveChangesAsync();
}
@ -218,7 +218,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<FollowUps> AddAsync(FollowUps followUp, CancellationToken cancellationToken)
{
followUp.CreatedDate = DateTime.UtcNow;
followUp.CreatedDate = DateTime.Now;
await _context.FollowUps.AddAsync(followUp, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return followUp;
@ -226,7 +226,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken)
{
followUp.LastModificationTime = DateTime.UtcNow;
followUp.LastModificationTime = DateTime.Now;
_context.FollowUps.Update(followUp);
await _context.SaveChangesAsync(cancellationToken);
}
@ -238,7 +238,7 @@ namespace SeaHaven.DataServices.Implementation
return false;
entity.Status = status;
entity.LastModificationTime = DateTime.UtcNow;
entity.LastModificationTime = DateTime.Now;
await _context.SaveChangesAsync(cancellationToken);
return true;
}

View file

@ -1,7 +1,5 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -17,25 +15,25 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations?> GetByIdAsync(int id)
{
return await _context.Locations.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
return await _context.Locations.FindAsync(id);
}
public async Task<Locations?> GetByIdWithDetailsAsync(int id)
{
return await _context.Locations
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
.FirstOrDefaultAsync(l => l.Id == id);
}
public async Task<IEnumerable<Locations>> GetAllAsync()
{
return await _context.Locations.Where(l => l.IsDeleted != true).ToListAsync();
return await _context.Locations.ToListAsync();
}
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
{
return await _context.Locations
.AsNoTracking()
.Where(l => l.AccountId == accountId && l.IsDeleted != true)
.Where(l => l.AccountId == accountId)
.ToListAsync();
}
@ -45,7 +43,7 @@ namespace SeaHaven.DataServices.Implementation
{
var row = await _context.Locations
.AsNoTracking()
.Where(l => l.Id == locationId && l.IsDeleted != true)
.Where(l => l.Id == locationId)
.Select(l => new { l.AccountId })
.FirstOrDefaultAsync(cancellationToken);
@ -57,7 +55,7 @@ namespace SeaHaven.DataServices.Implementation
int pageSize,
string? search = null)
{
var query = _context.Locations.Where(l => l.IsDeleted != true);
var query = _context.Locations.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
@ -82,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation
{
var query = _context.Locations
.AsNoTracking()
.Where(l => l.AccountId != null && l.IsDeleted != true);
.Where(l => l.AccountId != null);
if (!string.IsNullOrWhiteSpace(search))
{
@ -100,7 +98,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations> AddAsync(Locations location)
{
location.CreatedDate = DateTime.UtcNow;
location.CreatedDate = DateTime.Now;
await _context.Locations.AddAsync(location);
await _context.SaveChangesAsync();
return location;
@ -108,7 +106,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Locations location)
{
location.LastModificationTime = DateTime.UtcNow;
location.LastModificationTime = DateTime.Now;
_context.Locations.Update(location);
await _context.SaveChangesAsync();
}
@ -125,12 +123,12 @@ namespace SeaHaven.DataServices.Implementation
public async Task<bool> ExistsAsync(int id)
{
return await _context.Locations.AnyAsync(l => l.Id == id && l.IsDeleted != true);
return await _context.Locations.AnyAsync(l => l.Id == id);
}
public async Task<int> CountAsync()
{
return await _context.Locations.CountAsync(l => l.IsDeleted != true);
return await _context.Locations.CountAsync();
}
public async Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
@ -144,8 +142,7 @@ namespace SeaHaven.DataServices.Implementation
{
IQueryable<Locations> query = _context.Locations
.AsNoTracking()
.Include(l => l.Account)
.Where(l => l.IsDeleted != true);
.Include(l => l.Account);
if (!string.IsNullOrWhiteSpace(search))
{
@ -203,16 +200,15 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Include(l => l.Account)
.Include(l => l.Contacts.Where(c => c.IsDeleted != true))
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
@ -232,7 +228,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
{
location.CreatedDate = DateTime.UtcNow;
location.CreatedDate = DateTime.Now;
await _context.Locations.AddAsync(location, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return location;
@ -240,72 +236,36 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Locations location, CancellationToken cancellationToken)
{
location.LastModificationTime = DateTime.UtcNow;
location.LastModificationTime = DateTime.Now;
_context.Locations.Update(location);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> SiteCodeExistsAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken)
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
{
var normalized = siteCode.Trim().ToUpperInvariant();
var entity = await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
if (entity == null)
return false;
return await _context.Locations
.AsNoTracking()
.Where(l => l.IsDeleted != true
&& l.AccountId == accountId
&& l.Name != null
&& l.Name.Trim().ToUpper() == normalized)
.Where(l => excludeLocationId == null || l.Id != excludeLocationId)
.AnyAsync(cancellationToken);
var now = DateTime.Now;
foreach (var contact in entity.Contacts ?? Enumerable.Empty<Contacts>())
{
if (contact.IsDeleted != true)
{
contact.IsDeleted = true;
contact.DeletionTime = now;
}
contact.LocationId = null;
}
_context.Locations.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
int locationId,
string? siteCode,
int? siteAccountId,
int? callerAccountId,
int maxIds,
CancellationToken cancellationToken)
{
var code = string.IsNullOrWhiteSpace(siteCode) ? null : siteCode.Trim().ToUpper();
var query = _context.workOrders
.AsNoTracking()
.Where(w => w.IsDeleted != true
&& (w.LocationId == locationId
|| (code != null
&& w.LocationId == null
&& w.SiteCode != null
&& w.SiteCode.Trim().ToUpper() == code
&& (w.AccountId == null || w.AccountId == siteAccountId))));
if (callerAccountId is int accountId)
query = query.Where(w => w.AccountId == accountId);
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
var count = await query.CountAsync(cancellationToken);
if (count == 0)
return (0, Array.Empty<int>());
var ids = await query
.OrderBy(w => w.Id)
.Select(w => w.Id)
.Take(maxIds)
.ToListAsync(cancellationToken);
return (count, ids);
}
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
.GetValues<LifecycleStatus>()
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
.ToList();
public async Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(
int page,
int pageSize,
@ -313,7 +273,7 @@ namespace SeaHaven.DataServices.Implementation
{
search ??= "";
var query = _context.Locations.Where(l => l.IsDeleted != true);
var query = _context.Locations.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{

View file

@ -14,8 +14,6 @@ namespace SeaHaven.DataServices.Implementation
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" };
private const string AssignToAuditField = "AssignTo";
private const string LegacyAssignedToAuditField = "AssignedTo";
@ -178,89 +176,6 @@ namespace SeaHaven.DataServices.Implementation
return await candidates.Take(limit).ToListAsync(cancellationToken);
}
public async Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
NotificationFeedScope scope,
NotificationSlaCutoffs atRisk,
NotificationSlaCutoffs breached,
int breachedLimit,
CancellationToken cancellationToken)
{
var eligible = SlaEligibleWorkOrders(scope);
// Bounded by the longest window: nothing created more than 72 hours ago is still at risk.
var atRiskItems = await ProjectSla(CreatedAfter(CreatedAtOrBefore(eligible, atRisk), breached))
.OrderBy(candidate => candidate.CreatedAt)
.ThenBy(candidate => candidate.Id)
.ToListAsync(cancellationToken);
// Breaches stay until acknowledged, so legacy open work orders can make this set large.
var unacknowledged = CreatedAtOrBefore(eligible, breached)
.Where(w => !_context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
&& log.NewValue == w.Severity));
var breachedTotal = await unacknowledged.CountAsync(cancellationToken);
var breachedItems = breachedTotal == 0
? new List<NotificationSlaCandidate>()
: await ProjectSla(unacknowledged)
.OrderByDescending(candidate => candidate.CreatedAt)
.ThenByDescending(candidate => candidate.Id)
.Take(breachedLimit)
.ToListAsync(cancellationToken);
return new NotificationSlaCandidates
{
AtRisk = atRiskItems,
Breached = breachedItems,
BreachedTotal = breachedTotal
};
}
public Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken)
=> ProjectSla(SlaEligibleWorkOrders(scope).Where(w => w.Id == workOrderId))
.FirstOrDefaultAsync(cancellationToken);
// Same rule as the board's severity facet: Reactive or Emergency with a stored SEV level "1"–"5".
private IQueryable<WorkOrder> SlaEligibleWorkOrders(NotificationFeedScope scope)
=> OpenWorkOrders(scope).Where(w =>
(w.WorkOrderType == WorkOrderType.Reactive || w.WorkOrderType == WorkOrderType.Emergency)
&& SlaSeverities.Contains(w.Severity ?? "")
&& w.CreatedDate != null);
private static IQueryable<WorkOrder> CreatedAtOrBefore(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
{
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
return workOrders.Where(w =>
(w.Severity == "1" && w.CreatedDate <= sev1)
|| (w.Severity == "2" && w.CreatedDate <= sev2)
|| (w.Severity == "3" && w.CreatedDate <= sev3)
|| (w.Severity == "4" && w.CreatedDate <= sev4)
|| (w.Severity == "5" && w.CreatedDate <= sev5));
}
private static IQueryable<WorkOrder> CreatedAfter(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
{
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
return workOrders.Where(w =>
(w.Severity == "1" && w.CreatedDate > sev1)
|| (w.Severity == "2" && w.CreatedDate > sev2)
|| (w.Severity == "3" && w.CreatedDate > sev3)
|| (w.Severity == "4" && w.CreatedDate > sev4)
|| (w.Severity == "5" && w.CreatedDate > sev5));
}
private IQueryable<NotificationSlaCandidate> ProjectSla(IQueryable<WorkOrder> workOrders)
=> workOrders.Select(w => new NotificationSlaCandidate
{
Id = w.Id,
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
Severity = w.Severity!,
CreatedAt = w.CreatedDate!.Value,
BreachAcknowledged = _context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
&& log.NewValue == w.Severity)
});
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());

View file

@ -46,8 +46,8 @@ namespace SeaHaven.DataServices.Implementation
}
public async Task<(IEnumerable<PMSchedules> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
int page,
int pageSize,
string? search = null)
{
var query = _context.PMSchedules.AsQueryable();
@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<PMSchedules> AddAsync(PMSchedules schedule)
{
schedule.CreatedDate = DateTime.UtcNow;
schedule.CreatedDate = DateTime.Now;
await _context.PMSchedules.AddAsync(schedule);
await _context.SaveChangesAsync();
return schedule;
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(PMSchedules schedule)
{
schedule.LastModificationTime = DateTime.UtcNow;
schedule.LastModificationTime = DateTime.Now;
_context.PMSchedules.Update(schedule);
await _context.SaveChangesAsync();
}

View file

@ -1,144 +0,0 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class TeamMemberInviteDataService : ITeamMemberInviteDataService
{
private readonly ApplicationDbContext _context;
public TeamMemberInviteDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
_context.TeamMemberInvites.Add(invite);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task ReplaceOpenForUserAsync(
TeamMemberInvite invite,
DateTime now,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
await _context.TeamMemberInvites
.Where(existing => existing.UserId == invite.UserId
&& existing.UsedAt == null
&& existing.RevokedAt == null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(existing => existing.RevokedAt, now),
cancellationToken);
_context.TeamMemberInvites.Add(invite);
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
{
return _context.TeamMemberInvites
.AsNoTracking()
.Where(invite => invite.TokenHash == tokenHash)
.Select(invite => new TeamMemberInviteData
{
Id = invite.Id,
UserId = invite.UserId,
ExpiresAt = invite.ExpiresAt,
UsedAt = invite.UsedAt,
RevokedAt = invite.RevokedAt,
CodeHash = invite.CodeHash,
CodeSalt = invite.CodeSalt,
CodeExpiresAt = invite.CodeExpiresAt,
CodeFailedAttempts = invite.CodeFailedAttempts,
CodeSentAt = invite.CodeSentAt,
CodeSendCount = invite.CodeSendCount,
EmailConfirmedAt = invite.EmailConfirmedAt
})
.SingleOrDefaultAsync(cancellationToken);
}
public async Task<bool> TryStartCodeAsync(
StartTeamMemberInviteCodeCommand command,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
var updated = await OpenInvite(command.InviteId, command.Now)
.Where(invite => invite.CodeSendCount < command.MaxSends
&& (invite.CodeSentAt == null || invite.CodeSentAt <= command.LastSentNoLaterThan))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(invite => invite.CodeHash, command.CodeHash)
.SetProperty(invite => invite.CodeSalt, command.CodeSalt)
.SetProperty(invite => invite.CodeExpiresAt, command.CodeExpiresAt)
.SetProperty(invite => invite.CodeFailedAttempts, 0)
.SetProperty(invite => invite.CodeSentAt, command.Now)
.SetProperty(invite => invite.CodeSendCount, invite => invite.CodeSendCount + 1),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryReserveCodeAttemptAsync(
int inviteId,
int maxAttempts,
DateTime now,
CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.CodeHash != null
&& invite.CodeExpiresAt > now
&& invite.CodeFailedAttempts < maxAttempts)
.ExecuteUpdateAsync(
setters => setters.SetProperty(
invite => invite.CodeFailedAttempts,
invite => invite.CodeFailedAttempts + 1),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryConfirmEmailAsync(
int inviteId,
string codeHash,
DateTime now,
CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.CodeHash == codeHash && invite.CodeExpiresAt > now)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(invite => invite.EmailConfirmedAt, now)
.SetProperty(invite => invite.CodeHash, (string?)null)
.SetProperty(invite => invite.CodeSalt, (string?)null)
.SetProperty(invite => invite.CodeExpiresAt, (DateTime?)null)
.SetProperty(invite => invite.CodeFailedAttempts, 0),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.EmailConfirmedAt != null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(invite => invite.UsedAt, now),
cancellationToken);
return updated == 1;
}
private IQueryable<TeamMemberInvite> OpenInvite(int inviteId, DateTime now)
{
return _context.TeamMemberInvites.Where(invite => invite.Id == inviteId
&& invite.UsedAt == null
&& invite.RevokedAt == null
&& invite.ExpiresAt > now);
}
}
}

View file

@ -11,9 +11,6 @@ namespace SeaHaven.DataServices.Implementation
public class UpliftDataService : IUpliftDataService
{
private static readonly ConcurrentDictionary<int, SemaphoreSlim> WorkOrderGates = new();
// The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected.
private static readonly string[] RejectedStatuses = { "Rejected", "Denied" };
private readonly ApplicationDbContext _context;
public UpliftDataService(ApplicationDbContext context)
@ -49,9 +46,7 @@ namespace SeaHaven.DataServices.Implementation
&& (d.IsDeleted == null || d.IsDeleted == false)
select new { u, d, v, ev, effectiveWorkOrderId, workOrder, reqUser, decUser };
if (string.Equals(status, "Rejected", StringComparison.Ordinal))
query = query.Where(x => RejectedStatuses.Contains(x.u.Status));
else if (!string.IsNullOrWhiteSpace(status))
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.u.Status == status);
if (tier.HasValue)
query = query.Where(x => x.u.RequiredTier == tier.Value);
@ -59,13 +54,12 @@ namespace SeaHaven.DataServices.Implementation
var total = await query.CountAsync(cancellationToken);
// Approval queue read contract: the actionable queue (Pending) surfaces the
// oldest request first; the decision logs (Approved, Rejected) surface the
// most recently decided first. Every other read keeps the historical
// oldest request first; the decision log (Approved) surfaces the most
// recently decided first. Every other read keeps the historical
// newest-request-first order. Id is the deterministic tiebreaker.
if (string.Equals(status, "Pending", StringComparison.Ordinal))
query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id);
else if (string.Equals(status, "Approved", StringComparison.Ordinal)
|| string.Equals(status, "Rejected", StringComparison.Ordinal))
else if (string.Equals(status, "Approved", StringComparison.Ordinal))
query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id);
else
query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);
@ -249,9 +243,6 @@ namespace SeaHaven.DataServices.Implementation
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
// Vendor sessions have no identity user, so createdby is null
// only on requests the vendor raised in the portal.
RaisedByVendor = u.createdby == null,
CreatedDate = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,

View file

@ -159,10 +159,6 @@ namespace SeaHaven.DataServices.Implementation
.Where(permissionOverride => permissionOverride.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.TeamMemberInvites
.Where(invite => invite.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.Users
.Where(existingUser => existingUser.Id == id)
.ExecuteDeleteAsync(cancellationToken);

View file

@ -17,39 +17,6 @@ namespace SeaHaven.DataServices.Implementation
// supporting evidence never participates in completion versioning or replacement.
private const string CompletionPurpose = "Completion";
public async Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId,
int? excludingDocumentId,
CancellationToken cancellationToken)
{
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
// a newer completion version supersedes a document.
return await _context.VendorCompletionDocuments
.AsNoTracking()
.Where(document => document.WorkOrderId == workOrderId
&& (document.IsDeleted == null || document.IsDeleted == false)
&& (excludingDocumentId == null || document.Id != excludingDocumentId)
&& !_context.VendorCompletionDocuments.Any(newer =>
newer.ReplacesDocumentId == document.Id
&& newer.Purpose == CompletionPurpose
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
.Select(document => document.ContentType)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(attachment => attachment.WorkorderId == workOrderId
&& attachment.IsDeleted != true
&& attachment.Attachments != null)
.Select(attachment => attachment.Attachments!)
.ToListAsync(cancellationToken);
}
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return _context.VendorCompletionDocuments

View file

@ -150,7 +150,7 @@ namespace SeaHaven.DataServices.Implementation
}
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
=> _context.Locations.AnyAsync(location => location.Id == locationId && location.IsDeleted != true, cancellationToken);
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))

View file

@ -19,31 +19,10 @@ namespace SeaHaven.DataServices.Implementation
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (query.AccountId.HasValue)
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
// An exact id set (a link from an alert or a linked-work-orders list) shows those work
// orders whatever their date, status or owner; only the base and tenant scope still apply.
baseQuery = query.Ids is { Count: > 0 } ids
? baseQuery.Where(w => ids.Contains(w.Id))
: ApplyFilters(baseQuery, query);
var totalCount = await baseQuery.CountAsync();
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
}
private IQueryable<WorkOrder> ApplyFilters(IQueryable<WorkOrder> baseQuery, WorkOrderAdvancedSearchQuery query)
{
if (query.UnscheduledOnly)
baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery);
else
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
baseQuery, query.DateFrom, query.DateTo, query.IncludeDateless);
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
@ -67,7 +46,15 @@ namespace SeaHaven.DataServices.Implementation
if (normalizedSearch != null)
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
return baseQuery;
var totalCount = await baseQuery.CountAsync();
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
}
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)

View file

@ -41,6 +41,9 @@ namespace SeaHaven.DataServices.Implementation
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}

View file

@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<WorkOrder> AddAsync(WorkOrder workOrder)
{
workOrder.CreatedDate ??= DateTime.UtcNow;
workOrder.CreatedDate = DateTime.Now;
await _context.workOrders.AddAsync(workOrder);
await _context.SaveChangesAsync();
return workOrder;
@ -201,7 +201,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(WorkOrder workOrder)
{
workOrder.LastModificationTime = DateTime.UtcNow;
workOrder.LastModificationTime = DateTime.Now;
_context.workOrders.Update(workOrder);
await _context.SaveChangesAsync();
}

View file

@ -56,36 +56,6 @@ namespace SeaHaven.DataServices.Implementation
public void TrackAttachment(WorkOrderAttachments attachment)
=> _context.workOrderAttachments.Add(attachment);
public async Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken)
{
var urls = await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true && a.Attachments != null)
.Select(a => a.Attachments!)
.ToListAsync(cancellationToken);
return urls;
}
public async Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId,
CancellationToken cancellationToken)
{
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
// a newer completion version supersedes a document.
return await _context.VendorCompletionDocuments
.AsNoTracking()
.Where(document => document.WorkOrderId == workOrderId
&& (document.IsDeleted == null || document.IsDeleted == false)
&& !_context.VendorCompletionDocuments.Any(newer =>
newer.ReplacesDocumentId == document.Id
&& newer.Purpose == "Completion"
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
.Select(document => document.ContentType)
.ToListAsync(cancellationToken);
}
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;

View file

@ -1,26 +0,0 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record CompletionDocTemplateProcedureRow(
int Id,
int SortOrder,
string Name,
string Description);
// Read model for the completion document templates registry: the legacy
// columns plus authored content and audit display data.
public record CompletionDocTemplateDetailRow(
int Id,
string Name,
string ServiceKey,
WorkOrderType? WorkOrderType,
string TemplateUrl,
bool IsActive,
string? ExtraSafetyNote,
DateTime? CreatedAt,
DateTime? UpdatedAt,
string? CreatedByFirstName,
string? CreatedByLastName,
IReadOnlyList<CompletionDocTemplateProcedureRow> Procedures);
}

View file

@ -14,18 +14,6 @@ namespace SeaHaven.DataServices.Interfaces
DateOnly today,
CancellationToken cancellationToken);
/// <summary>
/// Open work orders with no dispatcher in the period, counted with the
/// same filters as GET /board/search for Dispatcher=Unassigned,
/// so the Dashboard number matches the list it drills into.
/// </summary>
Task<int> CountOpenUnassignedAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken);
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,

View file

@ -52,31 +52,6 @@ namespace SeaHaven.DataServices.Interfaces
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
/// <summary>
/// True when a live (not deleted) site of the same account already uses
/// <paramref name="siteCode"/>, compared trimmed and case-insensitively.
/// A null account matches only other sites without an account.
/// </summary>
Task<bool> SiteCodeExistsAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken);
/// <summary>
/// Open (not Completed or Canceled, legacy status aware) and not deleted work
/// orders that reference the site by id, or by site code when they carry no
/// location id and no other account. <paramref name="callerAccountId"/>
/// narrows to one account. Returns the full count and at most
/// <paramref name="maxIds"/> ids, ascending.
/// </summary>
Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
int locationId,
string? siteCode,
int? siteAccountId,
int? callerAccountId,
int maxIds,
CancellationToken cancellationToken);
Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken);
}
}

View file

@ -48,22 +48,5 @@ namespace SeaHaven.DataServices.Interfaces
/// </summary>
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
/// <summary>
/// Open Reactive/Emergency work orders with a SEV 1–5 level, split by creation time: at risk when
/// created at or before <paramref name="atRisk"/> but after <paramref name="breached"/>, breached when
/// created at or before <paramref name="breached"/> and not yet acknowledged for their current level.
/// Breaches are the <paramref name="breachedLimit"/> newest; the at-risk set is bounded by the longest window.
/// </summary>
Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
NotificationFeedScope scope,
NotificationSlaCutoffs atRisk,
NotificationSlaCutoffs breached,
int breachedLimit,
CancellationToken cancellationToken);
/// <summary>The work order as an SLA candidate inside <paramref name="scope"/>, or null when it is not one.</summary>
Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken);
}
}

View file

@ -1,30 +0,0 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Dto;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// Persistence for team member registration invites. Every state transition that
/// guards a security limit is a single conditional update, so concurrent requests
/// cannot exceed the limit; each returns whether the transition happened.
/// </summary>
public interface ITeamMemberInviteDataService
{
Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken);
/// <summary>Revokes every open invite for the invite's user and adds the new one, atomically.</summary>
Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken);
Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken);
Task<bool> TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken);
/// <summary>Counts one verification attempt against a live code; false once the attempt limit is used up.</summary>
Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken);
Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken);
/// <summary>Marks an open, unexpired, email-confirmed invite as used; false when another request already did.</summary>
Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken);
}
}

View file

@ -9,19 +9,6 @@ namespace SeaHaven.DataServices.Interfaces
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor documents (not deleted, not replaced by a
/// newer completion version), optionally excluding one being replaced. Feeds the per-work-order photo/video counts.
/// </summary>
Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId,
int? excludingDocumentId,
CancellationToken cancellationToken);
/// <summary>Stored URLs of the work order's non-deleted dispatcher attachments (photo/video counts).</summary>
Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View file

@ -15,6 +15,7 @@ namespace SeaHaven.DataServices.Interfaces
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -25,43 +25,11 @@ namespace SeaHaven.DataServices.Interfaces
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken);
/// <summary>Tracked, not-deleted template with its procedures loaded, for editing.</summary>
Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken);
Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken);
/// <summary>
/// Commits edits to a template from <see cref="GetTrackedForEditAsync"/>. A non-null
/// <paramref name="replacementProcedures"/> replaces the whole procedure list.
/// </summary>
Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken);
/// <summary>
/// Soft-deletes the template and clears every Service link to it in the same commit.
/// Services keep RequiresCompletionDocument. False when the template does not exist.
/// </summary>
Task<bool> DeleteAndUnlinkServicesAsync(int id, string? deleterUserId, CancellationToken cancellationToken);
/// <summary>
/// Ids of open (non-terminal) work orders whose Service links this template,
/// restricted to <paramref name="accountId"/> when set.
/// </summary>
Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
}
}

View file

@ -22,19 +22,6 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
/// <summary>Stored URLs of the work order's non-deleted attachments (photo/video counts).</summary>
Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor-portal documents (not deleted, not
/// replaced by a newer completion version). The counts span both upload surfaces.
/// </summary>
Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId,
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
void MarkWorkOrderModified(WorkOrder workOrder);
Task SaveAsync(CancellationToken cancellationToken);

View file

@ -32,9 +32,7 @@ namespace SeaHaven.DataServices.Interfaces
int PageSize,
string SortBy,
string SortDir,
int? AccountId = null,
bool IncludeDateless = false,
IReadOnlyList<int>? Ids = null);
int? AccountId = null);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,

View file

@ -64,38 +64,3 @@ public sealed class NotificationUpliftDecisionCandidate
public string? DecisionNote { get; init; }
public DateTime DecidedAt { get; init; }
}
/// <summary>
/// Per SEV level, the latest work-order creation time that has reached a response-window threshold.
/// The business service computes them; the query only compares creation times.
/// </summary>
public sealed record NotificationSlaCutoffs(DateTime Sev1, DateTime Sev2, DateTime Sev3, DateTime Sev4, DateTime Sev5);
/// <summary>
/// The work-order audit entry that records an SLA breach acknowledgement: who (UserId), when (CreatedAt)
/// and the SEV level whose deadline was missed (NewValue). It is also what keeps the breach out of the feed.
/// </summary>
public static class SlaBreachAcknowledgementAudit
{
public const string FieldName = "SlaBreachAcknowledged";
}
/// <summary>An open Reactive/Emergency work order with a SEV 1–5 level and a creation time.</summary>
public sealed class NotificationSlaCandidate
{
public int Id { get; init; }
public string? Number { get; init; }
public string Severity { get; init; } = "";
public DateTime CreatedAt { get; init; }
/// <summary>A breach of the work order's current severity has already been acknowledged.</summary>
public bool BreachAcknowledged { get; init; }
}
public sealed class NotificationSlaCandidates
{
/// <summary>Every work order past half its window but not past the whole window.</summary>
public IReadOnlyList<NotificationSlaCandidate> AtRisk { get; init; } = Array.Empty<NotificationSlaCandidate>();
/// <summary>Unacknowledged breaches: the newest slice, plus the unbounded total.</summary>
public IReadOnlyList<NotificationSlaCandidate> Breached { get; init; } = Array.Empty<NotificationSlaCandidate>();
public int BreachedTotal { get; init; }
}

View file

@ -1,174 +0,0 @@
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using System.Security.Claims;
using Xunit;
namespace SeaHaven.Services.Tests;
public sealed class TeamEffectivePermissionsTests
{
[Fact]
public async Task Scheduler_Receives_Role_Defaults()
{
var data = new FakeUsers().Add("u1", "Scheduler");
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value!.Permissions.Should().Contain(new[]
{
TeamPermissionKeys.CreateCompletionDocTemplates,
TeamPermissionKeys.EditCompletionDocTemplates
});
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.DeleteCompletionDocTemplates);
}
[Fact]
public async Task Grant_Override_Adds_A_Key_Outside_The_Role_Defaults()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher", (TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
}
[Fact]
public async Task Revoke_Override_Removes_A_Role_Default()
{
var data = new FakeUsers().Add(
"u1", "Scheduler", (TeamPermissionKeys.EditCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
result.Value.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
}
[Fact]
public async Task Admin_Holds_Every_Key_Even_With_A_Revoke_Override()
{
var data = new FakeUsers().Add(
"u1", "Admin", (TeamPermissionKeys.DeleteCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(TeamPermissionKeys.All);
}
[Fact]
public async Task Keys_Match_What_The_Write_Path_Enforces()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher",
(TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow),
(TeamPermissionKeys.CreateSites, UserPermissionState.Deny));
var policy = new TeamPermissionPolicy();
var user = await data.GetUserAsync("u1", CancellationToken.None);
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(
TeamPermissionKeys.All.Where(key => policy.IsAllowed(user!.RoleName, key, user.Overrides)));
}
[Fact]
public async Task Reads_Only_The_Caller_Identified_By_The_Token()
{
var data = new FakeUsers()
.Add("u1", "Dispatcher")
.Add("u2", "Dispatcher", (TeamPermissionKeys.DeleteWorkOrders, UserPermissionState.Allow));
using var cancellation = new CancellationTokenSource();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), cancellation.Token);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.DeleteWorkOrders);
data.RequestedUserIds.Should().Equal("u1");
data.LastToken.Should().Be(cancellation.Token);
}
[Fact]
public async Task Unauthenticated_Caller_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var anonymous = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }));
var result = await Service(data).GetEffectivePermissionsAsync(anonymous, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_Without_A_User_Id_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var noId = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
var result = await Service(data).GetEffectivePermissionsAsync(noId, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_For_A_Removed_User_Is_Rejected()
{
var data = new FakeUsers();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("gone"), CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
result.Value.Should().BeNull();
}
private static TeamPermissionService Service(FakeUsers data) =>
new(data, new TeamPermissionPolicy());
private static ClaimsPrincipal Caller(string userId) =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"));
private sealed class FakeUsers : ITeamPermissionOverrideDataService
{
private readonly Dictionary<string, TeamPermissionUserData> _users = new();
public List<string> RequestedUserIds { get; } = new();
public CancellationToken LastToken { get; private set; }
public FakeUsers Add(string userId, string role, params (string Key, UserPermissionState State)[] overrides)
{
_users[userId] = new TeamPermissionUserData
{
UserId = userId,
RoleName = role,
Overrides = overrides.ToDictionary(o => o.Key, o => o.State, StringComparer.OrdinalIgnoreCase)
};
return this;
}
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
{
RequestedUserIds.Add(userId);
LastToken = cancellationToken;
return Task.FromResult(_users.GetValueOrDefault(userId));
}
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task SetOverridesAsync(string userId, IReadOnlyDictionary<string, UserPermissionState> overrides, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
}
}

View file

@ -106,8 +106,6 @@ namespace SeaHaven.Services.DTOs
public int ScheduledTomorrow { get; set; }
public int PendingUplifts { get; set; }
public int AvetaPending { get; set; }
/// <summary>Open work orders with no dispatcher in the selected period.</summary>
public int Unassigned { get; set; }
public DashboardBreakdownDTO Breakdown { get; set; } = new();
public int DueCount { get; set; }
public int CompletedDueCount { get; set; }

View file

@ -11,20 +11,6 @@ namespace SeaHaven.Services.DTOs
public string Id { get; set; } = string.Empty;
}
public enum ChangePasswordStatus
{
Succeeded,
CurrentPasswordIncorrect,
PasswordRejected,
/// <summary>Identity failed for a reason other than the password policy.</summary>
Failed
}
public sealed class ChangePasswordResultDTO
{
public ChangePasswordStatus Status { get; init; }
}
public class UpdateProfileRequestDTO
{
public string? Name { get; set; }

View file

@ -15,7 +15,6 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int? AccountId { get; set; }
public string? AccountName { get; set; }
public string? Notes { get; set; }
public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; }
@ -71,7 +70,6 @@ namespace SeaHaven.Services.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
public string? Notes { get; set; }
/// <summary>SH-138: null keeps legacy behavior; empty array is a validation error.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
@ -90,9 +88,6 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int? AccountId { get; set; }
/// <summary>Null keeps the stored notes.</summary>
public string? Notes { get; set; }
/// <summary>SH-138: null keeps legacy behavior and must not mutate contact rows.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
}
@ -104,18 +99,4 @@ namespace SeaHaven.Services.DTOs
public string? City { get; set; }
public string? State { get; set; }
}
/// <summary>Site contacts and notes edited from the work-order Site dialog.</summary>
public class SiteContactInfoRequestDTO
{
public List<SiteContactRequestDTO>? Contacts { get; set; }
public string? Notes { get; set; }
}
/// <summary>Open (not Completed or Canceled) work orders that reference a site.</summary>
public class SiteOpenWorkOrdersDTO
{
public int Count { get; set; }
public IReadOnlyList<int> WorkOrderIds { get; set; } = Array.Empty<int>();
}
}

View file

@ -126,26 +126,9 @@ namespace SeaHaven.Services.DTOs
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
}
/// <summary>
/// A work order past half of its SEV response window and not yet past the whole window, with the
/// server-computed clock. Drives the top-of-app banner and the one-time 50% toast.
/// </summary>
public sealed class NotificationSlaWorkOrderDto
{
public int Id { get; init; }
public string? Number { get; init; }
public int Severity { get; init; }
/// <summary>The work order's creation time; the response clock never starts at the scheduled date.</summary>
public DateTime StartedAt { get; init; }
public DateTime DeadlineAt { get; init; }
public int PercentElapsed { get; init; }
}
public sealed class NotificationFeedDto
{
public DateTime GeneratedAt { get; init; }
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
/// <summary>Every at-risk work order in the feed's scope, oldest first; not capped like section items.</summary>
public IReadOnlyList<NotificationSlaWorkOrderDto> SlaAtRisk { get; init; } = Array.Empty<NotificationSlaWorkOrderDto>();
}
}

Some files were not shown because too many files have changed in this diff Show more