mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 19:03:22 +00:00
Compare commits
67 commits
0f011f842c
...
59917d718a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59917d718a | ||
|
|
cf32dd2698 | ||
|
|
c6dcad8395 | ||
|
|
9084b7f642 | ||
|
|
fc3a29f399 | ||
|
|
47060f6a73 | ||
|
|
bb41bfd8ed | ||
|
|
77e54e64e3 | ||
|
|
a8d05776a8 | ||
|
|
5353899418 | ||
|
|
cc328ce22a | ||
|
|
afc2330184 | ||
|
|
e53415de39 | ||
|
|
9091335ff2 | ||
|
|
b50cd5f5df | ||
|
|
f491d4c721 | ||
|
|
e09ef061d1 | ||
|
|
e9a1d8f53f | ||
|
|
0088cffd47 | ||
|
|
236199ab7a | ||
|
|
85b7d1e1c7 | ||
|
|
1c8da7f344 | ||
|
|
385229c64d | ||
|
|
227691269d | ||
|
|
27c21ec32e | ||
|
|
6bfb56f349 | ||
|
|
13fec977fa | ||
|
|
c8073123e3 | ||
|
|
92dabbfbe3 | ||
|
|
f9cdbaa06b | ||
|
|
c0ae8479ce | ||
|
|
c1910e5310 | ||
|
|
a224f883bc | ||
|
|
ed5c75223e | ||
|
|
d82fb18a3d | ||
|
|
60b1afd8e0 | ||
|
|
cd23ad5b68 | ||
|
|
c3865e56ac | ||
|
|
14c8e46dd0 | ||
|
|
66a49ab957 | ||
|
|
7c097c2750 | ||
|
|
b545d4a4fe | ||
|
|
3019e71093 | ||
|
|
06eae2fb02 | ||
|
|
702069f09c | ||
|
|
d6c5357fab | ||
|
|
c49a98db18 | ||
|
|
2c8ffaf10e | ||
|
|
c1ed5dc98d | ||
|
|
207bf59208 | ||
|
|
203fc92e4a | ||
|
|
e02f9774dc | ||
|
|
67cd9c589b | ||
|
|
0d8f32d148 | ||
|
|
f3ef11b504 | ||
|
|
3e3f0f383d | ||
|
|
eecc2a61bd | ||
|
|
7297e30212 | ||
|
|
fd59a3da13 | ||
|
|
a8414cd4fa | ||
|
|
e15de6e90b | ||
|
|
16845a55f3 | ||
|
|
07bc81cc52 | ||
|
|
4876fa2717 | ||
|
|
9d5798437d | ||
|
|
dc251c42d4 | ||
|
|
028908bd5a |
174 changed files with 23423 additions and 852 deletions
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v4.3.0
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }}
|
||||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v4.3.0
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }}
|
||||
|
|
@ -88,7 +88,7 @@ jobs:
|
|||
review:
|
||||
name: review
|
||||
if: github.event_name != 'push'
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13
|
||||
|
||||
ci-complete:
|
||||
name: ci-complete
|
||||
|
|
|
|||
2
.github/workflows/deploy.yaml
vendored
2
.github/workflows/deploy.yaml
vendored
|
|
@ -176,7 +176,7 @@ jobs:
|
|||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
|
|
|
|||
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal file
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# 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;
|
||||
|
|
@ -117,11 +117,11 @@ public class AuthenticationControllerTests
|
|||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded });
|
||||
|
||||
var controller = NewController(service, "42");
|
||||
|
||||
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None);
|
||||
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", 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_Failure_ReturnsOldPasswordIncorrectStatus()
|
||||
public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus()
|
||||
{
|
||||
var service = new Mock<IAuthenticationService>();
|
||||
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect });
|
||||
|
||||
var controller = NewController(service, "42");
|
||||
|
||||
|
|
@ -143,6 +143,70 @@ 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]
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public class CalendarServiceTests
|
|||
StartDate = startDate,
|
||||
EndDate = startDate,
|
||||
IsDeleted = isDeleted,
|
||||
CreatedDate = DateTime.Now
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
ctx.Events.Add(ev);
|
||||
ctx.SaveChanges();
|
||||
|
|
@ -64,6 +64,7 @@ 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]
|
||||
|
|
@ -97,6 +98,7 @@ 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]
|
||||
|
|
@ -134,6 +136,7 @@ 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]
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ public class DashboardControllerTests
|
|||
{
|
||||
ScheduledTomorrow = 3,
|
||||
PendingUplifts = 5,
|
||||
AvetaPending = 7
|
||||
AvetaPending = 7,
|
||||
Unassigned = 11
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetStats(
|
||||
|
|
@ -50,5 +51,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,10 +213,10 @@ public class DashboardServiceTests
|
|||
AccountUser(1), query, CancellationToken.None);
|
||||
|
||||
stats.Total.Should().Be(4);
|
||||
stats.Breakdown.Overdue.Should().Be(1);
|
||||
stats.Breakdown.Overdue.Should().Be(0);
|
||||
stats.Breakdown.Other.Should().Be(0);
|
||||
stats.Breakdown.PM.Should().Be(2);
|
||||
stats.Breakdown.Emergency.Should().Be(0);
|
||||
stats.Breakdown.Emergency.Should().Be(1);
|
||||
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);
|
||||
|
|
|
|||
249
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal file
249
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,9 @@ public class LocationControllerSitesTests
|
|||
dataService,
|
||||
new AccountDataService(ctx),
|
||||
Mock.Of<ICreateLocationValidation>(),
|
||||
Mock.Of<IUpdateLocationValidation>());
|
||||
Mock.Of<IUpdateLocationValidation>(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -286,4 +286,80 @@ 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" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@ 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>()
|
||||
|
|
@ -28,7 +49,9 @@ public class LocationServiceTests
|
|||
new LocationDataService(ctx),
|
||||
new AccountDataService(ctx),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer")
|
||||
{
|
||||
|
|
@ -83,7 +106,7 @@ public class LocationServiceTests
|
|||
SeedAccount(ctx, 9);
|
||||
var service = NewService(ctx);
|
||||
|
||||
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
Title = "Main WH",
|
||||
|
|
@ -95,7 +118,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");
|
||||
|
|
@ -211,7 +234,9 @@ public class LocationServiceTests
|
|||
dataService,
|
||||
Mock.Of<IAccountDataService>(),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
|
||||
|
|
@ -237,14 +262,14 @@ public class LocationServiceTests
|
|||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||
{
|
||||
Name = "New",
|
||||
Name = "Old",
|
||||
Address = "9 New St",
|
||||
City = "Plano",
|
||||
Status = "Inactive"
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var row = ctx.Locations.Single();
|
||||
row.Name.Should().Be("New");
|
||||
row.Name.Should().Be("Old", "the site code is immutable");
|
||||
row.Address1.Should().Be("9 New St");
|
||||
row.City.Should().Be("Plano");
|
||||
row.Status.Should().Be("Inactive");
|
||||
|
|
@ -263,7 +288,7 @@ public class LocationServiceTests
|
|||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||
{
|
||||
Name = "New",
|
||||
Name = "Old",
|
||||
City = "Plano"
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
|
|
@ -301,12 +326,12 @@ public class LocationServiceTests
|
|||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
existing.Id,
|
||||
new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" },
|
||||
new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" },
|
||||
AccountUser(4),
|
||||
CancellationToken.None);
|
||||
|
||||
var row = ctx.Locations.Single();
|
||||
row.Name.Should().Be("Renamed");
|
||||
row.Name.Should().Be("Owned");
|
||||
row.City.Should().Be("Austin");
|
||||
row.AccountId.Should().Be(4);
|
||||
}
|
||||
|
|
@ -386,7 +411,7 @@ public class LocationServiceTests
|
|||
using var ctx = NewContext();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }),
|
||||
OrgWideAdmin(),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -402,7 +427,7 @@ public class LocationServiceTests
|
|||
ctx.SaveChanges();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }),
|
||||
OrgWideAdmin(),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -418,7 +443,7 @@ public class LocationServiceTests
|
|||
SeedAccount(ctx, 99);
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Site", AccountId = 99 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }),
|
||||
AccountUser(4),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -453,7 +478,7 @@ public class LocationServiceTests
|
|||
SeedAccount(ctx, 9);
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
|
||||
MissingScope(),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -476,12 +501,14 @@ public class LocationServiceTests
|
|||
new LocationDataService(ctx),
|
||||
accounts.Object,
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
|
||||
OrgWideAdmin(),
|
||||
cts.Token);
|
||||
|
||||
|
|
@ -520,20 +547,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,27 @@ 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>()
|
||||
|
|
@ -35,7 +56,9 @@ public class LocationSiteContactsTests
|
|||
new LocationDataService(ctx),
|
||||
new AccountDataService(ctx),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
private static ClaimsPrincipal OrgWideAdmin()
|
||||
{
|
||||
|
|
@ -51,27 +74,28 @@ public class LocationSiteContactsTests
|
|||
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
|
||||
{
|
||||
var service = NewService(ctx);
|
||||
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, 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_MirrorsFirstPhone_SetsAccountFromLocation()
|
||||
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_KeepsSitePhoneIndependent_SetsAccountFromLocation()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
AccountId = 7,
|
||||
|
|
@ -80,10 +104,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().Be("555-0100", "first contact mirrors Location.PhoneNumber");
|
||||
location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts");
|
||||
|
||||
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
|
||||
contacts.Should().HaveCount(2);
|
||||
|
|
@ -104,11 +128,11 @@ public class LocationSiteContactsTests
|
|||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, 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");
|
||||
|
|
@ -127,11 +151,11 @@ public class LocationSiteContactsTests
|
|||
using var ctx = NewContext();
|
||||
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
Contacts = contacts
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
if (expectedError == null)
|
||||
{
|
||||
|
|
@ -148,14 +172,14 @@ public class LocationSiteContactsTests
|
|||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, 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"));
|
||||
|
|
@ -170,11 +194,11 @@ public class LocationSiteContactsTests
|
|||
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
|
||||
.ToList();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, 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"));
|
||||
|
|
@ -220,7 +244,7 @@ public class LocationSiteContactsTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
|
||||
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var seeded = await SeedLocationWithContactsAsync(ctx);
|
||||
|
|
@ -239,7 +263,7 @@ public class LocationSiteContactsTests
|
|||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var location = ctx.Locations.Include(l => l.Contacts).Single();
|
||||
location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact");
|
||||
location.PhoneNumber.Should().BeNull("Site Phone comes from the request, not the first contact");
|
||||
|
||||
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
|
||||
active.Should().HaveCount(3);
|
||||
|
|
@ -304,7 +328,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-0100", "the rejected update must not persist any change");
|
||||
.Should().Be("555-9000", "the rejected update must not persist any change");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -448,7 +472,9 @@ public class LocationSiteContactsTests
|
|||
data.Object,
|
||||
Mock.Of<IAccountDataService>(),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
|
|
@ -489,67 +515,6 @@ 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);
|
||||
|
|
@ -608,7 +573,7 @@ public class LocationSiteContactsTests
|
|||
using var json = JsonSerializer.SerializeToDocument(ok.Value);
|
||||
var root = json.RootElement;
|
||||
|
||||
root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact");
|
||||
root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts");
|
||||
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);
|
||||
|
|
@ -636,7 +601,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-0100");
|
||||
row.GetProperty("Phone").GetString().Should().Be("555-9000");
|
||||
row.GetProperty("Contacts").GetArrayLength().Should().Be(2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ 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;
|
||||
|
|
@ -546,7 +548,7 @@ public class NotificationFeedServiceTests
|
|||
public async Task Controller_MapsForbiddenScopeTo403()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var controller = new NotificationsController(NewService(context))
|
||||
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
|
|
@ -565,7 +567,7 @@ public class NotificationFeedServiceTests
|
|||
using var context = NewContext();
|
||||
context.workOrders.Add(Open(1));
|
||||
await context.SaveChangesAsync();
|
||||
var controller = new NotificationsController(NewService(context))
|
||||
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
|
|
|
|||
402
Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs
Normal file
402
Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
218
Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
Normal file
218
Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal file
105
Api.SeaHavenIndustries.Tests/SendMessageTests.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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}");
|
||||
}
|
||||
}
|
||||
503
Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs
Normal file
503
Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
171
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal file
171
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
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"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ public sealed class TeamMemberServiceTests
|
|||
Mock.Of<IUserServiceAreaDataService>(),
|
||||
Mock.Of<ITeamPermissionOverrideDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<ITeamPermissionService>());
|
||||
Mock.Of<ITeamPermissionService>(),
|
||||
Mock.Of<ITeamMemberInviteService>());
|
||||
|
||||
var result = await service.CreateAsync(
|
||||
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
||||
|
|
@ -408,7 +409,8 @@ public sealed class TeamMemberServiceTests
|
|||
areas.Object,
|
||||
overrides.Object,
|
||||
userData.Object,
|
||||
permissions.Object);
|
||||
permissions.Object,
|
||||
Mock.Of<ITeamMemberInviteService>());
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> UserManager()
|
||||
|
|
|
|||
|
|
@ -164,6 +164,59 @@ 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()
|
||||
{
|
||||
|
|
@ -558,6 +611,40 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -496,6 +496,92 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ 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>());
|
||||
|
|
@ -363,6 +368,278 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
217
Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs
Normal file
217
Api.SeaHavenIndustries.Tests/WorkOrderIdsFilterTests.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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) }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
|
|
@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void AddMedia_HasTwoHundredMegabyteRequestLimit()
|
||||
public void AddMedia_HasContractRequestLimit()
|
||||
{
|
||||
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
||||
var attribute = Assert.Single(
|
||||
|
|
@ -39,36 +40,293 @@ public class WorkOrderMediaControllerTests
|
|||
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
|
||||
var bytes = Assert.Single(attribute.ConstructorArguments);
|
||||
|
||||
Assert.Equal(200_000_000L, bytes.Value);
|
||||
Assert.Equal(110_000_000L, bytes.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit()
|
||||
public void AddMedia_HasContractMultipartBodyLimit()
|
||||
{
|
||||
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
||||
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
|
||||
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
|
||||
|
||||
Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit);
|
||||
Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
|
||||
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
|
||||
{
|
||||
var file = new Mock<IFormFile>();
|
||||
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
|
||||
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
|
||||
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||
var controller = CreateController(storage: storage);
|
||||
|
||||
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
|
||||
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("The uploaded file must not exceed 200 MB.", error.Message);
|
||||
Assert.Equal("Videos must be 100 MB or smaller.", 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()
|
||||
{
|
||||
|
|
@ -167,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests
|
|||
mediaData.Object,
|
||||
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
||||
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
||||
storage.Object);
|
||||
storage.Object,
|
||||
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
|
|
@ -184,4 +443,37 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. 52 routes
|
||||
/// come from 51 actions (Editworkorder binds two routes).
|
||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 53 routes
|
||||
/// come from 52 actions (Editworkorder binds two routes).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
@ -60,6 +60,7 @@ 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",
|
||||
|
|
|
|||
|
|
@ -33,16 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
|
||||
if (result != null)
|
||||
{
|
||||
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 Ok(LoginPayload.From(result));
|
||||
}
|
||||
|
||||
return Unauthorized();
|
||||
|
|
@ -55,20 +46,33 @@ 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 succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
|
||||
if (succeeded)
|
||||
var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken);
|
||||
return result.Status switch
|
||||
{
|
||||
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
|
||||
}
|
||||
else
|
||||
return BadRequest(new Response { Status = "Old Password is incorrect" });
|
||||
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" })
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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
|
||||
|
|
@ -105,6 +106,9 @@ 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()
|
||||
};
|
||||
|
||||
|
|
@ -119,6 +123,10 @@ 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));
|
||||
|
|
@ -142,6 +150,10 @@ 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));
|
||||
|
|
@ -161,17 +173,73 @@ 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, cancellationToken);
|
||||
var found = await _locationService.DeleteLocationByIdAsync(id, User, 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" });
|
||||
|
|
@ -188,6 +256,14 @@ 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
|
||||
|
|
@ -202,6 +278,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = model.ContactEmail,
|
||||
Status = model.Status,
|
||||
AccountId = model.GetAccountId(),
|
||||
Notes = model.Notes,
|
||||
Contacts = MapSiteContacts(model.Contacts)
|
||||
};
|
||||
}
|
||||
|
|
@ -220,6 +297,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = model.ContactEmail,
|
||||
Status = model.Status,
|
||||
AccountId = model.GetAccountId(),
|
||||
Notes = model.Notes,
|
||||
Contacts = MapSiteContacts(model.Contacts)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,50 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
public class NotificationsController : ControllerBase
|
||||
{
|
||||
private readonly INotificationFeedService _feedService;
|
||||
private readonly ISlaBreachAcknowledgementService _slaAcknowledgement;
|
||||
|
||||
public NotificationsController(INotificationFeedService feedService)
|
||||
public NotificationsController(
|
||||
INotificationFeedService feedService,
|
||||
ISlaBreachAcknowledgementService slaAcknowledgement)
|
||||
{
|
||||
_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]
|
||||
|
|
|
|||
|
|
@ -11,10 +11,39 @@ 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)
|
||||
public TeamMemberController(
|
||||
ITeamMemberService teamMemberService,
|
||||
ITeamPermissionService permissionService,
|
||||
ITeamMemberInviteService inviteService)
|
||||
{
|
||||
_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]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ 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." }),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
|
@ -299,7 +300,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
[HttpPost("dispatches/{id:int}/completion-documents")]
|
||||
[HttpPost("dispatches/{id:int}/documents")]
|
||||
[RequestSizeLimit(10_000_000)]
|
||||
[RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)]
|
||||
public async Task<IActionResult> UploadCompletionDocument(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
|
|
|
|||
|
|
@ -18,83 +18,111 @@ 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)
|
||||
[FromQuery] WorkOrderType? workOrderType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
|
||||
var templates = await _completionDocTemplateService.ListAsync(search, serviceKey, workOrderType, cancellationToken);
|
||||
return Ok(templates);
|
||||
}
|
||||
|
||||
[HttpGet("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> GetCompletionTemplate(int id)
|
||||
public async Task<IActionResult> GetCompletionTemplate(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
|
||||
var template = await _completionDocTemplateService.GetAsync(id, cancellationToken);
|
||||
if (template == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Template not found." });
|
||||
return Ok(template);
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("completion-templates")]
|
||||
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
|
||||
[HttpGet("completion-templates/{id:int}/linked-work-orders")]
|
||||
public async Task<IActionResult> GetCompletionTemplateLinkedWorkOrders(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
|
||||
var linked = await _completionDocTemplateService.GetLinkedWorkOrdersAsync(User, id, cancellationToken);
|
||||
return Ok(linked);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return MapTemplateError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("completion-templates")]
|
||||
public async Task<IActionResult> CreateCompletionTemplate(
|
||||
[FromBody] CompletionDocTemplateCreateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await _completionDocTemplateService.CreateAsync(User, request, cancellationToken);
|
||||
return Ok(created);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
return MapTemplateError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
|
||||
public async Task<IActionResult> UpdateCompletionTemplate(
|
||||
int id,
|
||||
[FromBody] CompletionDocTemplateCreateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
|
||||
var updated = await _completionDocTemplateService.UpdateAsync(User, id, request, cancellationToken);
|
||||
return Ok(updated);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
return MapTemplateError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> DeleteCompletionTemplate(int id)
|
||||
public async Task<IActionResult> DeleteCompletionTemplate(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _workOrderCompletionService.DeleteTemplateAsync(id);
|
||||
await _completionDocTemplateService.DeleteAsync(User, id, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
return MapTemplateError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[Route("api/workorders")]
|
||||
public class WorkOrderMediaController : Controller
|
||||
{
|
||||
public const long MaxUploadBytes = 200_000_000;
|
||||
public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes;
|
||||
|
||||
private readonly IWorkOrderMediaService _workOrderMediaService;
|
||||
private readonly IFileStoragePort _fileStorage;
|
||||
|
|
@ -88,14 +88,28 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
string? fileUrl = null;
|
||||
try
|
||||
{
|
||||
if (file.Length > MaxUploadBytes)
|
||||
// Type first, so an unsupported file always reports UnsupportedMediaType instead
|
||||
// of being sized under a kind it does not have.
|
||||
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",
|
||||
"The uploaded file must not exceed 200 MB.");
|
||||
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
|
||||
}
|
||||
|
||||
WorkOrderMediaFileRules.EnsureAllowed(file, category);
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ 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; }
|
||||
|
|
@ -24,6 +31,7 @@ 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);
|
||||
|
|
|
|||
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal file
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
public class SendMessage : IEmailSender
|
||||
{
|
||||
private IConfiguration _configuration;
|
||||
private readonly ILogger<SendMessage> _logger;
|
||||
//string keysapi = "";
|
||||
|
||||
public SendMessage(IConfiguration configuration)
|
||||
public SendMessage(IConfiguration configuration, ILogger<SendMessage> logger)
|
||||
{
|
||||
_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
|
||||
|
|
@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
|
||||
|
||||
//var apiKey = "<SENDGRID_API_KEY>";
|
||||
var client = new SendGridClient(apiKey);
|
||||
var client = CreateClient(apiKey);
|
||||
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
|
||||
//var subject = "Sending with SendGrid is Fun";
|
||||
var to = new EmailAddress(emailTo, "");
|
||||
|
|
@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
var htmlContent = body;
|
||||
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
|
||||
var response = await client.SendEmailAsync(msg);
|
||||
var dd = response.Body.ReadAsStringAsync();
|
||||
return true;
|
||||
return Delivered(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return false;
|
||||
return Failed(ex);
|
||||
}
|
||||
}
|
||||
public async Task<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
|
||||
|
|
@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
|
||||
|
||||
//var apiKey = "<SENDGRID_API_KEY>";
|
||||
var client = new SendGridClient(apiKey);
|
||||
var client = CreateClient(apiKey);
|
||||
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
|
||||
//var subject = "Sending with SendGrid is Fun";
|
||||
var to = new EmailAddress(emailTo, "");
|
||||
|
|
@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
msg.Attachments = new List<SendGrid.Helpers.Mail.Attachment> { attachment };
|
||||
|
||||
var response = await client.SendEmailAsync(msg);
|
||||
|
||||
var dd = response.Body.ReadAsStringAsync();
|
||||
return true;
|
||||
return Delivered(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return false;
|
||||
return Failed(ex);
|
||||
}
|
||||
}
|
||||
public async Task<bool> SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
|
||||
|
|
@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
try
|
||||
{
|
||||
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
|
||||
var client = new SendGridClient(apiKey);
|
||||
var client = CreateClient(apiKey);
|
||||
var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries");
|
||||
var to = new EmailAddress(emailTo, "");
|
||||
|
||||
|
|
@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
msg.SetReplyTo(new EmailAddress(replyTo));
|
||||
|
||||
var response = await client.SendEmailAsync(msg);
|
||||
return true;
|
||||
return Delivered(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return false;
|
||||
return Failed(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using Api.SeaHavenIndustries.Helper;
|
||||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Api.SeaHavenIndustries.Middleware;
|
||||
using Api.SeaHavenIndustries.Observability;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
|
|
@ -43,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration;
|
|||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
builder.Services.AddSeaHavenIdentity();
|
||||
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -54,6 +54,23 @@ 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)
|
||||
|
|
@ -311,6 +328,21 @@ 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 });
|
||||
|
|
@ -344,6 +376,7 @@ 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; }
|
||||
|
|
@ -388,6 +421,7 @@ 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()
|
||||
{
|
||||
|
|
|
|||
47
Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs
Normal file
47
Data.SeaHavenIndustries/Auth/IdentityPasswordPolicy.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ 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
|
||||
}
|
||||
|
||||
|
|
|
|||
4263
Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.Designer.cs
generated
Normal file
4263
Data.SeaHavenIndustries/Migrations/20260925135234_CompletionDocTemplateProcedures.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,59 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
4216
Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs
generated
Normal file
4216
Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
4289
Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs
generated
Normal file
4289
Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,64 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -523,6 +523,10 @@ 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");
|
||||
|
||||
|
|
@ -560,6 +564,36 @@ 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")
|
||||
|
|
@ -1582,6 +1616,9 @@ 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)");
|
||||
|
||||
|
|
@ -2067,6 +2104,71 @@ 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")
|
||||
|
|
@ -3481,6 +3583,17 @@ 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")
|
||||
|
|
@ -3785,6 +3898,17 @@ 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")
|
||||
|
|
@ -4108,6 +4232,11 @@ 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");
|
||||
|
|
|
|||
|
|
@ -4,10 +4,32 @@ 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; } = "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ 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; }
|
||||
|
|
|
|||
44
Data.SeaHavenIndustries/Models/TeamMemberInvite.cs
Normal file
44
Data.SeaHavenIndustries/Models/TeamMemberInvite.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,9 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ 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
|
||||
|
|
|
|||
36
SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs
Normal file
36
SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ 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,
|
||||
|
|
@ -134,7 +135,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);
|
||||
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes, w.SiteNotes);
|
||||
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
|
||||
? w.DispatchTechPhone
|
||||
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ 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));
|
||||
|
||||
|
|
@ -50,9 +58,9 @@ namespace SeaHaven.DataServices.Helpers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
|
|
@ -86,8 +94,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 =>
|
||||
|
|
@ -155,6 +163,44 @@ 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)
|
||||
|
|
@ -162,7 +208,22 @@ namespace SeaHaven.DataServices.Helpers
|
|||
if (statuses == null || statuses.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
|
||||
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()))))));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
|
||||
|
|
@ -220,13 +281,26 @@ 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)
|
||||
DateOnly dateTo,
|
||||
bool includeDateless = false)
|
||||
{
|
||||
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
|
||||
|
|
@ -234,9 +308,10 @@ namespace SeaHaven.DataServices.Helpers
|
|||
&& w.ScheduledDate.Value.Date <= toDate)
|
||||
|| (w.ScheduleWeekOnly == true
|
||||
&& w.TargetWeek != null
|
||||
&& w.TargetWeek >= dateFrom
|
||||
&& w.TargetWeek >= earliestWeekStart
|
||||
&& w.TargetWeek <= dateTo)
|
||||
|| (w.ScheduledDate == null
|
||||
|| (includeDateless
|
||||
&& w.ScheduledDate == null
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ 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))
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Accounts> AddAsync(Accounts account)
|
||||
{
|
||||
account.CreatedDate = DateTime.Now;
|
||||
account.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
account.LastModificationTime = DateTime.UtcNow;
|
||||
_context.Accounts.Update(account);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Assets> AddAsync(Assets asset)
|
||||
{
|
||||
asset.CreatedDate = DateTime.Now;
|
||||
asset.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
asset.LastModificationTime = DateTime.UtcNow;
|
||||
_context.Assets.Update(asset);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Category> AddAsync(Category category)
|
||||
{
|
||||
category.CreatedDate = DateTime.Now;
|
||||
category.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
category.LastModificationTime = DateTime.UtcNow;
|
||||
_context.Categories.Update(category);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
return await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(n => n.IsDeleted != true)
|
||||
.Select(n => new Locations
|
||||
{
|
||||
Id = n.Id,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
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)
|
||||
|
|
@ -14,33 +19,6 @@ 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))
|
||||
|
|
@ -70,33 +48,149 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
|
||||
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)
|
||||
{
|
||||
template.CreatedDate = DateTime.UtcNow;
|
||||
await _context.CompletionDocTemplates.AddAsync(template);
|
||||
await _context.SaveChangesAsync();
|
||||
return template;
|
||||
await _context.CompletionDocTemplates.AddAsync(template, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return template.Id;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(CompletionDocTemplate template)
|
||||
public async Task SaveEditAsync(
|
||||
CompletionDocTemplate template,
|
||||
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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;
|
||||
_context.CompletionDocTemplates.Update(template);
|
||||
await _context.SaveChangesAsync();
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
public async Task<bool> DeleteAndUnlinkServicesAsync(
|
||||
int id,
|
||||
string? deleterUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _context.CompletionDocTemplates.FindAsync(id);
|
||||
if (entity == null)
|
||||
var template = await _context.CompletionDocTemplates
|
||||
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
|
||||
if (template == null)
|
||||
return false;
|
||||
|
||||
entity.IsDeleted = true;
|
||||
entity.DeletionTime = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Contacts> AddAsync(Contacts contact)
|
||||
{
|
||||
contact.CreatedDate = DateTime.Now;
|
||||
contact.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
contact.LastModificationTime = DateTime.UtcNow;
|
||||
_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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,41 @@ 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,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Dispatch> AddAsync(Dispatch dispatch)
|
||||
{
|
||||
dispatch.CreatedDate = DateTime.Now;
|
||||
dispatch.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
dispatch.LastModificationTime = DateTime.UtcNow;
|
||||
_context.Dispatches.Update(dispatch);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Employee> AddAsync(Employee employee)
|
||||
{
|
||||
employee.CreatedDate = DateTime.Now;
|
||||
employee.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
employee.LastModificationTime = DateTime.UtcNow;
|
||||
_context.Employees.Update(employee);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<FollowUps> AddAsync(FollowUps followUp)
|
||||
{
|
||||
followUp.CreatedDate = DateTime.Now;
|
||||
followUp.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
followUp.LastModificationTime = DateTime.UtcNow;
|
||||
_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.Now;
|
||||
followUp.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
followUp.LastModificationTime = DateTime.UtcNow;
|
||||
_context.FollowUps.Update(followUp);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
|
@ -238,7 +238,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return false;
|
||||
|
||||
entity.Status = status;
|
||||
entity.LastModificationTime = DateTime.Now;
|
||||
entity.LastModificationTime = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
|
|
@ -15,25 +17,25 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Locations?> GetByIdAsync(int id)
|
||||
{
|
||||
return await _context.Locations.FindAsync(id);
|
||||
return await _context.Locations.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<Locations?> GetByIdWithDetailsAsync(int id)
|
||||
{
|
||||
return await _context.Locations
|
||||
.FirstOrDefaultAsync(l => l.Id == id);
|
||||
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Locations>> GetAllAsync()
|
||||
{
|
||||
return await _context.Locations.ToListAsync();
|
||||
return await _context.Locations.Where(l => l.IsDeleted != true).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
|
||||
{
|
||||
return await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.AccountId == accountId)
|
||||
.Where(l => l.AccountId == accountId && l.IsDeleted != true)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
var row = await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.Id == locationId)
|
||||
.Where(l => l.Id == locationId && l.IsDeleted != true)
|
||||
.Select(l => new { l.AccountId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
int pageSize,
|
||||
string? search = null)
|
||||
{
|
||||
var query = _context.Locations.AsQueryable();
|
||||
var query = _context.Locations.Where(l => l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
@ -80,7 +82,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
var query = _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.AccountId != null);
|
||||
.Where(l => l.AccountId != null && l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
@ -98,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Locations> AddAsync(Locations location)
|
||||
{
|
||||
location.CreatedDate = DateTime.Now;
|
||||
location.CreatedDate = DateTime.UtcNow;
|
||||
await _context.Locations.AddAsync(location);
|
||||
await _context.SaveChangesAsync();
|
||||
return location;
|
||||
|
|
@ -106,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task UpdateAsync(Locations location)
|
||||
{
|
||||
location.LastModificationTime = DateTime.Now;
|
||||
location.LastModificationTime = DateTime.UtcNow;
|
||||
_context.Locations.Update(location);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
@ -123,12 +125,12 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<bool> ExistsAsync(int id)
|
||||
{
|
||||
return await _context.Locations.AnyAsync(l => l.Id == id);
|
||||
return await _context.Locations.AnyAsync(l => l.Id == id && l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync()
|
||||
{
|
||||
return await _context.Locations.CountAsync();
|
||||
return await _context.Locations.CountAsync(l => l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||
|
|
@ -142,7 +144,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
IQueryable<Locations> query = _context.Locations
|
||||
.AsNoTracking()
|
||||
.Include(l => l.Account);
|
||||
.Include(l => l.Account)
|
||||
.Where(l => l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
@ -200,15 +203,16 @@ 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, cancellationToken);
|
||||
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Locations
|
||||
.Include(l => l.Contacts)
|
||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
|
||||
|
|
@ -228,7 +232,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
|
||||
{
|
||||
location.CreatedDate = DateTime.Now;
|
||||
location.CreatedDate = DateTime.UtcNow;
|
||||
await _context.Locations.AddAsync(location, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return location;
|
||||
|
|
@ -236,36 +240,72 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task UpdateAsync(Locations location, CancellationToken cancellationToken)
|
||||
{
|
||||
location.LastModificationTime = DateTime.Now;
|
||||
location.LastModificationTime = DateTime.UtcNow;
|
||||
_context.Locations.Update(location);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
||||
public async Task<bool> SiteCodeExistsAsync(
|
||||
string siteCode,
|
||||
int? accountId,
|
||||
int? excludeLocationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _context.Locations
|
||||
.Include(l => l.Contacts)
|
||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||
if (entity == null)
|
||||
return false;
|
||||
var normalized = siteCode.Trim().ToUpperInvariant();
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -273,7 +313,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
search ??= "";
|
||||
|
||||
var query = _context.Locations.AsQueryable();
|
||||
var query = _context.Locations.Where(l => l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ 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";
|
||||
|
||||
|
|
@ -176,6 +178,89 @@ 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());
|
||||
|
|
|
|||
|
|
@ -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.Now;
|
||||
schedule.CreatedDate = DateTime.UtcNow;
|
||||
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.Now;
|
||||
schedule.LastModificationTime = DateTime.UtcNow;
|
||||
_context.PMSchedules.Update(schedule);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ 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)
|
||||
|
|
@ -46,7 +49,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
&& (d.IsDeleted == null || d.IsDeleted == false)
|
||||
select new { u, d, v, ev, effectiveWorkOrderId, workOrder, reqUser, decUser };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
if (string.Equals(status, "Rejected", StringComparison.Ordinal))
|
||||
query = query.Where(x => RejectedStatuses.Contains(x.u.Status));
|
||||
else if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(x => x.u.Status == status);
|
||||
if (tier.HasValue)
|
||||
query = query.Where(x => x.u.RequiredTier == tier.Value);
|
||||
|
|
@ -54,12 +59,13 @@ 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 log (Approved) surfaces the most
|
||||
// recently decided first. Every other read keeps the historical
|
||||
// oldest request first; the decision logs (Approved, Rejected) surface 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))
|
||||
else if (string.Equals(status, "Approved", StringComparison.Ordinal)
|
||||
|| string.Equals(status, "Rejected", 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);
|
||||
|
|
@ -243,6 +249,9 @@ 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,
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@ 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);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,39 @@ 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
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
|
||||
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
|
||||
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
|
||||
=> _context.Locations.AnyAsync(location => location.Id == locationId && location.IsDeleted != true, cancellationToken);
|
||||
|
||||
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
|
||||
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))
|
||||
|
|
|
|||
|
|
@ -19,10 +19,31 @@ 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);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
|
||||
baseQuery, query.DateFrom, query.DateTo, query.IncludeDateless);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
|
||||
|
|
@ -46,15 +67,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
if (normalizedSearch != null)
|
||||
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
|
||||
|
||||
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);
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,6 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<WorkOrder> AddAsync(WorkOrder workOrder)
|
||||
{
|
||||
workOrder.CreatedDate = DateTime.Now;
|
||||
workOrder.CreatedDate ??= DateTime.UtcNow;
|
||||
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.Now;
|
||||
workOrder.LastModificationTime = DateTime.UtcNow;
|
||||
_context.workOrders.Update(workOrder);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,36 @@ 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -14,6 +14,18 @@ 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,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,31 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
|
||||
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
|
||||
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
|
||||
Task<bool> DeleteByIdAsync(int id, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,22 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,19 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
CancellationToken cancellationToken);
|
||||
|
||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,43 @@ 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<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
|
||||
Task UpdateAsync(CompletionDocTemplate template);
|
||||
Task<bool> DeleteAsync(int id);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,19 @@ 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);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int PageSize,
|
||||
string SortBy,
|
||||
string SortDir,
|
||||
int? AccountId = null);
|
||||
int? AccountId = null,
|
||||
bool IncludeDateless = false,
|
||||
IReadOnlyList<int>? Ids = null);
|
||||
|
||||
public record WorkOrderAdvancedSearchResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> Rows,
|
||||
|
|
|
|||
|
|
@ -64,3 +64,38 @@ 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; }
|
||||
}
|
||||
|
|
|
|||
174
SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs
Normal file
174
SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -106,6 +106,8 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,20 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ 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; }
|
||||
|
||||
|
|
@ -70,6 +71,7 @@ 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; }
|
||||
|
|
@ -88,6 +90,9 @@ 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; }
|
||||
}
|
||||
|
|
@ -99,4 +104,18 @@ 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>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue