mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 20:02:09 +00:00
Compare commits
8 commits
06eae2fb02
...
c8073123e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8073123e3 | ||
|
|
f9cdbaa06b | ||
|
|
a224f883bc | ||
|
|
ed5c75223e | ||
|
|
cd23ad5b68 | ||
|
|
14c8e46dd0 | ||
|
|
7c097c2750 | ||
|
|
3019e71093 |
36 changed files with 6879 additions and 183 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
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) }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -344,6 +361,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; }
|
||||
|
|
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -3481,6 +3515,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")
|
||||
|
|
@ -4108,6 +4153,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; } = "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -19,6 +19,26 @@ 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
|
||||
|
|
@ -47,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
string SortBy,
|
||||
string SortDir,
|
||||
int? AccountId = null,
|
||||
bool IncludeDateless = false);
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,9 +126,26 @@ namespace SeaHaven.Services.DTOs
|
|||
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A work order past half of its SEV response window and not yet past the whole window, with the
|
||||
/// server-computed clock. Drives the top-of-app banner and the one-time 50% toast.
|
||||
/// </summary>
|
||||
public sealed class NotificationSlaWorkOrderDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string? Number { get; init; }
|
||||
public int Severity { get; init; }
|
||||
/// <summary>The work order's creation time; the response clock never starts at the scheduled date.</summary>
|
||||
public DateTime StartedAt { get; init; }
|
||||
public DateTime DeadlineAt { get; init; }
|
||||
public int PercentElapsed { get; init; }
|
||||
}
|
||||
|
||||
public sealed class NotificationFeedDto
|
||||
{
|
||||
public DateTime GeneratedAt { get; init; }
|
||||
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
|
||||
/// <summary>Every at-risk work order in the feed's scope, oldest first; not capped like section items.</summary>
|
||||
public IReadOnlyList<NotificationSlaWorkOrderDto> SlaAtRisk { get; init; } = Array.Empty<NotificationSlaWorkOrderDto>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ namespace SeaHaven.Services.DTOs
|
|||
public bool HasUplift { get; set; }
|
||||
public List<string>? UpliftStatuses { get; set; }
|
||||
public bool MyWorkOrders { get; set; }
|
||||
/// <summary>
|
||||
/// Comma-separated work-order ids, at most <see cref="WorkOrderIdSet.MaxCount"/> distinct.
|
||||
/// When present the result is exactly those work orders inside the caller's tenant scope,
|
||||
/// and every other filter (dates, statuses, dispatchers, facets, search) is ignored.
|
||||
/// </summary>
|
||||
public string? Ids { get; set; }
|
||||
public string? SortBy { get; set; }
|
||||
public string? SortDir { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,13 +30,66 @@ namespace SeaHaven.Services.DTOs
|
|||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
// Completion document templates registry item and detail (same shape).
|
||||
public class CompletionDocTemplateDetailDto : CompletionDocTemplateDto
|
||||
{
|
||||
public string? ExtraSafetyNote { get; set; }
|
||||
public List<CompletionDocTemplateProcedureDto> Procedures { get; set; } = new();
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public string? CreatedByName { get; set; }
|
||||
}
|
||||
|
||||
public class CompletionDocTemplateProcedureDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
// Create/update body. Legacy fields are optional: on update, null leaves the
|
||||
// stored value unchanged, as does a null Procedures list. WorkOrderType is the
|
||||
// exception: omitting it keeps the stored value, while an explicit
|
||||
// "workOrderType": null clears it so the template applies to every work order
|
||||
// type of its trade. ExtraSafetyNote is always applied, so null removes the note.
|
||||
public class CompletionDocTemplateCreateDto
|
||||
{
|
||||
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;
|
||||
private WorkOrderType? _workOrderType;
|
||||
|
||||
public string? Name { get; set; }
|
||||
public string? ExtraSafetyNote { get; set; }
|
||||
public List<CompletionDocTemplateProcedureInputDto>? Procedures { get; set; }
|
||||
public string? ServiceKey { get; set; }
|
||||
|
||||
public WorkOrderType? WorkOrderType
|
||||
{
|
||||
get => _workOrderType;
|
||||
set
|
||||
{
|
||||
_workOrderType = value;
|
||||
WorkOrderTypeSpecified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// True when the body carried workOrderType at all, including as null.
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool WorkOrderTypeSpecified { get; private set; }
|
||||
|
||||
public string? TemplateUrl { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class CompletionDocTemplateProcedureInputDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class CompletionDocTemplateLinkedWorkOrdersDto
|
||||
{
|
||||
public int Count { get; set; }
|
||||
public List<int> WorkOrderIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WorkOrderCompletionDto
|
||||
|
|
|
|||
27
SeaHaven.Services/Helpers/NotificationAudience.cs
Normal file
27
SeaHaven.Services/Helpers/NotificationAudience.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>Whose work orders the Notification Center covers for the signed-in user.</summary>
|
||||
public static class NotificationAudience
|
||||
{
|
||||
// Dispatchers see their own work orders. Roles that already see every dispatcher's
|
||||
// work on the dashboard see the whole account, including work nobody owns yet.
|
||||
public static (string? DispatcherId, bool SeesUnassigned) Resolve(ClaimsPrincipal user)
|
||||
{
|
||||
if (user.IsInRole("Dispatcher"))
|
||||
{
|
||||
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
return (dispatcherId, false);
|
||||
}
|
||||
|
||||
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
||||
return (null, true);
|
||||
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
|
||||
}
|
||||
}
|
||||
}
|
||||
55
SeaHaven.Services/Helpers/SlaResponseWindows.cs
Normal file
55
SeaHaven.Services/Helpers/SlaResponseWindows.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public enum SlaState
|
||||
{
|
||||
OnTrack,
|
||||
/// <summary>At least half of the response window has elapsed.</summary>
|
||||
AtRisk,
|
||||
/// <summary>The whole response window has elapsed: the response deadline was missed.</summary>
|
||||
Breached
|
||||
}
|
||||
|
||||
/// <summary>Where one work order stands against its SEV response window.</summary>
|
||||
public sealed record SlaClock(int Severity, DateTime StartedAt, DateTime DeadlineAt, int PercentElapsed, SlaState State);
|
||||
|
||||
/// <summary>
|
||||
/// Respond deadlines from the client's SEV table, the same values the severity badge tooltip shows.
|
||||
/// They apply to Reactive and Emergency work orders with a severity, and the clock starts when the
|
||||
/// work order is created, never at its scheduled date. The table's "next day if after hours" note
|
||||
/// is not modelled: the clock always counts the plain hours.
|
||||
/// </summary>
|
||||
public static class SlaResponseWindows
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<int, TimeSpan> Respond = new Dictionary<int, TimeSpan>
|
||||
{
|
||||
[1] = TimeSpan.FromHours(2),
|
||||
[2] = TimeSpan.FromHours(4),
|
||||
[3] = TimeSpan.FromHours(8),
|
||||
[4] = TimeSpan.FromHours(24),
|
||||
[5] = TimeSpan.FromHours(72)
|
||||
};
|
||||
|
||||
/// <summary>The latest creation time at which a work order of this level has used half its window.</summary>
|
||||
public static DateTime AtRiskCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity] / 2;
|
||||
|
||||
/// <summary>The latest creation time at which a work order of this level has missed its deadline.</summary>
|
||||
public static DateTime BreachedCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity];
|
||||
|
||||
/// <summary>Null when the severity is not a SEV 1–5 level.</summary>
|
||||
public static SlaClock? Evaluate(string? severity, DateTime createdAt, DateTime now)
|
||||
{
|
||||
var level = WorkOrderSeverityRules.ParseLevel(severity);
|
||||
if (level is not int sev)
|
||||
return null;
|
||||
|
||||
var window = Respond[sev];
|
||||
var startedAt = DateTime.SpecifyKind(createdAt, DateTimeKind.Utc);
|
||||
var elapsed = now - startedAt;
|
||||
var state = elapsed >= window
|
||||
? SlaState.Breached
|
||||
: elapsed >= window / 2 ? SlaState.AtRisk : SlaState.OnTrack;
|
||||
var percent = (int)Math.Floor(Math.Max(elapsed.Ticks, 0) * 100d / window.Ticks);
|
||||
return new SlaClock(sev, startedAt, startedAt + window, percent, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
SeaHaven.Services/Helpers/WorkOrderIdSet.cs
Normal file
38
SeaHaven.Services/Helpers/WorkOrderIdSet.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// The board's exact work-order id filter: comma-separated positive integers, deduplicated,
|
||||
/// at most <see cref="MaxCount"/> distinct ids.
|
||||
/// </summary>
|
||||
public static class WorkOrderIdSet
|
||||
{
|
||||
public const int MaxCount = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Null when <paramref name="raw"/> is absent or blank. Throws <see cref="ArgumentException"/>
|
||||
/// (a 400 on the board) for any token that is not a positive integer, or too many ids.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<int>? ParseOrThrow(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
var ids = new List<int>();
|
||||
var seen = new HashSet<int>();
|
||||
foreach (var token in raw.Split(','))
|
||||
{
|
||||
if (!int.TryParse(token.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0)
|
||||
throw new ArgumentException("ids must be a comma-separated list of positive work order ids.");
|
||||
if (seen.Add(id))
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
if (ids.Count > MaxCount)
|
||||
throw new ArgumentException($"ids accepts at most {MaxCount} work orders.");
|
||||
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
259
SeaHaven.Services/Implementation/CompletionDocTemplateService.cs
Normal file
259
SeaHaven.Services/Implementation/CompletionDocTemplateService.cs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public sealed class CompletionDocTemplateService : ICompletionDocTemplateService
|
||||
{
|
||||
private readonly ICompletionDocTemplateDataService _templateData;
|
||||
private readonly ITeamPermissionOverrideDataService _permissionUsers;
|
||||
private readonly ITeamPermissionPolicy _permissionPolicy;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public CompletionDocTemplateService(
|
||||
ICompletionDocTemplateDataService templateData,
|
||||
ITeamPermissionOverrideDataService permissionUsers,
|
||||
ITeamPermissionPolicy permissionPolicy,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_templateData = templateData;
|
||||
_permissionUsers = permissionUsers;
|
||||
_permissionPolicy = permissionPolicy;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CompletionDocTemplateDetailDto>> ListAsync(
|
||||
string? search,
|
||||
string? serviceKey,
|
||||
WorkOrderType? workOrderType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await _templateData.ListAsync(search, serviceKey, workOrderType, cancellationToken);
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDetailDto?> GetAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await _templateData.GetDetailAsync(id, cancellationToken);
|
||||
return row == null ? null : Map(row);
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateLinkedWorkOrdersDto> GetLinkedWorkOrdersAsync(
|
||||
ClaimsPrincipal user,
|
||||
int id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
|
||||
if (await _templateData.GetDetailAsync(id, cancellationToken) == null)
|
||||
throw NotFound();
|
||||
|
||||
var ids = await _templateData.GetOpenLinkedWorkOrderIdsAsync(id, accountId, cancellationToken);
|
||||
return new CompletionDocTemplateLinkedWorkOrdersDto { Count = ids.Count, WorkOrderIds = ids.ToList() };
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDetailDto> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
CompletionDocTemplateCreateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actorId = await EnsureAllowedAsync(
|
||||
user,
|
||||
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||
"You do not have permission to create completion document templates.",
|
||||
cancellationToken);
|
||||
|
||||
var input = Validate(request);
|
||||
var entity = new CompletionDocTemplate
|
||||
{
|
||||
Name = input.Name,
|
||||
ExtraSafetyNote = input.ExtraSafetyNote,
|
||||
ServiceKey = input.ServiceKey ?? "",
|
||||
WorkOrderType = request.WorkOrderType,
|
||||
TemplateUrl = input.TemplateUrl ?? "",
|
||||
IsActive = request.IsActive ?? true,
|
||||
createdby = actorId,
|
||||
Procedures = (input.Procedures ?? new List<CompletionDocTemplateProcedure>()).ToList()
|
||||
};
|
||||
|
||||
var id = await _templateData.CreateAsync(entity, cancellationToken);
|
||||
return await GetSavedAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDetailDto> UpdateAsync(
|
||||
ClaimsPrincipal user,
|
||||
int id,
|
||||
CompletionDocTemplateCreateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAllowedAsync(
|
||||
user,
|
||||
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||
"You do not have permission to edit completion document templates.",
|
||||
cancellationToken);
|
||||
|
||||
var input = Validate(request);
|
||||
var entity = await _templateData.GetTrackedForEditAsync(id, cancellationToken) ?? throw NotFound();
|
||||
|
||||
entity.Name = input.Name;
|
||||
entity.ExtraSafetyNote = input.ExtraSafetyNote;
|
||||
if (input.ServiceKey != null)
|
||||
entity.ServiceKey = input.ServiceKey;
|
||||
if (request.WorkOrderTypeSpecified)
|
||||
entity.WorkOrderType = request.WorkOrderType;
|
||||
if (input.TemplateUrl != null)
|
||||
entity.TemplateUrl = input.TemplateUrl;
|
||||
if (request.IsActive.HasValue)
|
||||
entity.IsActive = request.IsActive.Value;
|
||||
|
||||
await _templateData.SaveEditAsync(entity, input.Procedures, cancellationToken);
|
||||
return await GetSavedAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var actorId = await EnsureAllowedAsync(
|
||||
user,
|
||||
TeamPermissionKeys.DeleteCompletionDocTemplates,
|
||||
"You do not have permission to delete completion document templates.",
|
||||
cancellationToken);
|
||||
|
||||
if (!await _templateData.DeleteAndUnlinkServicesAsync(id, actorId, cancellationToken))
|
||||
throw NotFound();
|
||||
}
|
||||
|
||||
private async Task<string> EnsureAllowedAsync(
|
||||
ClaimsPrincipal user,
|
||||
string permissionKey,
|
||||
string deniedMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var permissionUser = string.IsNullOrWhiteSpace(userId)
|
||||
? null
|
||||
: await _permissionUsers.GetUserAsync(userId, cancellationToken);
|
||||
|
||||
if (permissionUser is null
|
||||
|| !_permissionPolicy.IsAllowed(permissionUser.RoleName, permissionKey, permissionUser.Overrides))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("Forbidden", deniedMessage);
|
||||
}
|
||||
|
||||
return userId!;
|
||||
}
|
||||
|
||||
private async Task<CompletionDocTemplateDetailDto> GetSavedAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await _templateData.GetDetailAsync(id, cancellationToken) ?? throw NotFound();
|
||||
return Map(row);
|
||||
}
|
||||
|
||||
private sealed record ValidatedInput(
|
||||
string Name,
|
||||
string? ExtraSafetyNote,
|
||||
string? ServiceKey,
|
||||
string? TemplateUrl,
|
||||
IReadOnlyList<CompletionDocTemplateProcedure>? Procedures);
|
||||
|
||||
private static ValidatedInput Validate(CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Name))
|
||||
throw new WorkOrderBoardValidationException("TitleRequired", "Document Title is required.");
|
||||
|
||||
var extraSafetyNote = string.IsNullOrWhiteSpace(request.ExtraSafetyNote)
|
||||
? null
|
||||
: request.ExtraSafetyNote.Trim();
|
||||
if (extraSafetyNote?.Length > CompletionDocTemplate.ExtraSafetyNoteMaxLength)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
$"Extra safety note must be {CompletionDocTemplate.ExtraSafetyNoteMaxLength} characters or fewer.");
|
||||
}
|
||||
|
||||
var templateUrl = request.TemplateUrl?.Trim();
|
||||
if (!string.IsNullOrEmpty(templateUrl)
|
||||
&& (!Uri.TryCreate(templateUrl, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
|
||||
}
|
||||
|
||||
// Blank procedure names and descriptions are allowed: authors may keep a
|
||||
// placeholder step and fill it in later.
|
||||
List<CompletionDocTemplateProcedure>? procedures = null;
|
||||
if (request.Procedures != null)
|
||||
{
|
||||
procedures = new List<CompletionDocTemplateProcedure>(request.Procedures.Count);
|
||||
foreach (var procedure in request.Procedures)
|
||||
{
|
||||
var name = procedure?.Name?.Trim() ?? "";
|
||||
if (name.Length > CompletionDocTemplateProcedure.NameMaxLength)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
$"Procedure name must be {CompletionDocTemplateProcedure.NameMaxLength} characters or fewer.");
|
||||
}
|
||||
|
||||
procedures.Add(new CompletionDocTemplateProcedure
|
||||
{
|
||||
SortOrder = procedures.Count,
|
||||
Name = name,
|
||||
Description = procedure?.Description ?? ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidatedInput(
|
||||
request.Name.Trim(),
|
||||
extraSafetyNote,
|
||||
request.ServiceKey?.Trim(),
|
||||
templateUrl,
|
||||
procedures);
|
||||
}
|
||||
|
||||
private static WorkOrderBoardValidationException NotFound()
|
||||
=> new("NotFound", "Template not found.");
|
||||
|
||||
private static CompletionDocTemplateDetailDto Map(CompletionDocTemplateDetailRow row)
|
||||
{
|
||||
var createdByName = string.Join(
|
||||
" ",
|
||||
new[] { row.CreatedByFirstName, row.CreatedByLastName }
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||
.Select(part => part!.Trim()));
|
||||
|
||||
return new CompletionDocTemplateDetailDto
|
||||
{
|
||||
Id = row.Id,
|
||||
Name = row.Name,
|
||||
ServiceKey = row.ServiceKey,
|
||||
WorkOrderType = row.WorkOrderType,
|
||||
TemplateUrl = row.TemplateUrl,
|
||||
IsActive = row.IsActive,
|
||||
ExtraSafetyNote = row.ExtraSafetyNote,
|
||||
Procedures = row.Procedures
|
||||
.Select(p => new CompletionDocTemplateProcedureDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
SortOrder = p.SortOrder
|
||||
})
|
||||
.ToList(),
|
||||
CreatedAt = AsUtc(row.CreatedAt),
|
||||
UpdatedAt = AsUtc(row.UpdatedAt),
|
||||
CreatedByName = createdByName.Length == 0 ? null : createdByName
|
||||
};
|
||||
}
|
||||
|
||||
// Audit timestamps are written as DateTime.UtcNow; the store drops the kind.
|
||||
private static DateTime? AsUtc(DateTime? value)
|
||||
=> value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -41,11 +41,13 @@ namespace SeaHaven.Services.Implementation
|
|||
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
|
||||
var (dispatcherId, seesUnassigned) = NotificationAudience.Resolve(user);
|
||||
var scope = new NotificationFeedScope(accountId, dispatcherId);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
|
||||
var (slaSection, slaAtRisk) = await SlaAsync(scope, now, cancellationToken);
|
||||
sections.Add(slaSection);
|
||||
if (seesUnassigned)
|
||||
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
|
||||
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
|
||||
|
|
@ -59,28 +61,80 @@ namespace SeaHaven.Services.Implementation
|
|||
return new NotificationFeedDto
|
||||
{
|
||||
GeneratedAt = now,
|
||||
Sections = NotificationFeedOrdering.Order(sections)
|
||||
Sections = NotificationFeedOrdering.Order(sections),
|
||||
SlaAtRisk = slaAtRisk
|
||||
};
|
||||
}
|
||||
|
||||
// Dispatchers see their own work orders. Roles that already see every dispatcher's
|
||||
// work on the dashboard see the whole account, including work nobody owns yet.
|
||||
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
|
||||
// Reactive/Emergency work orders against their SEV response window. An at-risk work order is a
|
||||
// normal dismissable row (and feeds the banner); a missed deadline is an acknowledge row that stays
|
||||
// until someone acknowledges it. Breaches and at-risk rows are capped separately so a backlog of
|
||||
// old breaches can never hide a work order that can still be saved.
|
||||
private async Task<((string, int, IReadOnlyList<NotificationItemDto>) Section, IReadOnlyList<NotificationSlaWorkOrderDto> AtRisk)> SlaAsync(
|
||||
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (user.IsInRole("Dispatcher"))
|
||||
{
|
||||
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
return (dispatcherId, false);
|
||||
}
|
||||
var candidates = await _data.GetSlaCandidatesAsync(
|
||||
scope,
|
||||
SlaCutoffs(level => SlaResponseWindows.AtRiskCreatedAtOrBefore(level, now)),
|
||||
SlaCutoffs(level => SlaResponseWindows.BreachedCreatedAtOrBefore(level, now)),
|
||||
SectionItemLimit,
|
||||
cancellationToken);
|
||||
|
||||
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
||||
return (null, true);
|
||||
var atRisk = candidates.AtRisk
|
||||
.Select(candidate => (Candidate: candidate, Clock: SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now)))
|
||||
.Where(entry => entry.Clock is { State: SlaState.AtRisk })
|
||||
.Select(entry => new NotificationSlaWorkOrderDto
|
||||
{
|
||||
Id = entry.Candidate.Id,
|
||||
Number = entry.Candidate.Number,
|
||||
Severity = entry.Clock!.Severity,
|
||||
StartedAt = entry.Clock.StartedAt,
|
||||
DeadlineAt = entry.Clock.DeadlineAt,
|
||||
PercentElapsed = entry.Clock.PercentElapsed
|
||||
})
|
||||
.ToList();
|
||||
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
|
||||
var items = candidates.Breached
|
||||
.Select(candidate => SlaItem(candidate, SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now)))
|
||||
.OfType<NotificationItemDto>()
|
||||
.Concat(atRisk
|
||||
.OrderByDescending(workOrder => workOrder.PercentElapsed)
|
||||
.Take(SectionItemLimit)
|
||||
.Select(workOrder => new NotificationItemDto
|
||||
{
|
||||
Id = $"sla-at-risk-{workOrder.Id}",
|
||||
Reason = NotificationReasons.Sla,
|
||||
Severity = NotificationSeverities.High,
|
||||
Title = $"{WorkOrderLabel(workOrder.Id, workOrder.Number)} is at risk of missing its response deadline",
|
||||
Count = 1,
|
||||
TriggeredAt = workOrder.StartedAt + SlaResponseWindows.Respond[workOrder.Severity] / 2,
|
||||
Target = WorkOrderTarget(workOrder.Id, NotificationWorkOrderTabs.Info),
|
||||
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = workOrder.Id, Number = workOrder.Number } }
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
return ((NotificationReasons.Sla, candidates.BreachedTotal + atRisk.Count, items), atRisk);
|
||||
}
|
||||
|
||||
private static NotificationItemDto? SlaItem(NotificationSlaCandidate candidate, SlaClock? clock)
|
||||
=> clock is not { State: SlaState.Breached }
|
||||
? null
|
||||
: new NotificationItemDto
|
||||
{
|
||||
Id = $"sla-breach-{candidate.Id}",
|
||||
Reason = NotificationReasons.Sla,
|
||||
Severity = NotificationSeverities.Critical,
|
||||
RowType = NotificationRowTypes.Acknowledge,
|
||||
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} missed its response deadline",
|
||||
Count = 1,
|
||||
TriggeredAt = clock.DeadlineAt,
|
||||
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
|
||||
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
||||
};
|
||||
|
||||
private static NotificationSlaCutoffs SlaCutoffs(Func<int, DateTime> cutoff)
|
||||
=> new(cutoff(1), cutoff(2), cutoff(3), cutoff(4), cutoff(5));
|
||||
|
||||
// Items addressed to the signed-in user whatever their role: assignments to them, comments on
|
||||
// work they take part in, mentions of them, and decisions on uplifts they requested. The
|
||||
// account scope still applies; the dispatcher scope does not, because a mention or an uplift
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class SlaBreachAcknowledgementService : ISlaBreachAcknowledgementService
|
||||
{
|
||||
private readonly INotificationFeedDataService _feedData;
|
||||
private readonly IWorkOrderAuditDataService _auditData;
|
||||
private readonly IUserDataService _userData;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SlaBreachAcknowledgementService(
|
||||
INotificationFeedDataService feedData,
|
||||
IWorkOrderAuditDataService auditData,
|
||||
IUserDataService userData,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_feedData = feedData;
|
||||
_auditData = auditData;
|
||||
_userData = userData;
|
||||
_accountResolver = accountResolver;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<SlaBreachAcknowledgementOutcome> AcknowledgeAsync(
|
||||
ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
// The same audience as the feed: a row the caller cannot see cannot be acknowledged.
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var (dispatcherId, _) = NotificationAudience.Resolve(user);
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Acknowledging requires a signed-in user.");
|
||||
|
||||
var candidate = await _feedData.GetSlaCandidateAsync(
|
||||
new NotificationFeedScope(accountId, dispatcherId), workOrderId, cancellationToken);
|
||||
if (candidate == null)
|
||||
return SlaBreachAcknowledgementOutcome.NotFound;
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var clock = SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now);
|
||||
if (clock is not { State: SlaState.Breached })
|
||||
return SlaBreachAcknowledgementOutcome.NotBreached;
|
||||
if (candidate.BreachAcknowledged)
|
||||
return SlaBreachAcknowledgementOutcome.AlreadyAcknowledged;
|
||||
|
||||
var names = await _userData.GetDisplayNamesByIdsAsync(new[] { userId });
|
||||
var name = names.TryGetValue(userId, out var displayName) && !string.IsNullOrWhiteSpace(displayName)
|
||||
? displayName
|
||||
: user.FindFirstValue(ClaimTypes.Name) ?? userId;
|
||||
|
||||
_auditData.EnqueueAuditLog(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = userId,
|
||||
FieldName = SlaBreachAcknowledgementAudit.FieldName,
|
||||
OldValue = clock.DeadlineAt.ToString("o"),
|
||||
NewValue = candidate.Severity,
|
||||
Action = $"SLA breach acknowledged by {name}",
|
||||
EventType = "system",
|
||||
ActorType = "internal",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _auditData.PersistAsync(cancellationToken);
|
||||
return SlaBreachAcknowledgementOutcome.Acknowledged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ namespace SeaHaven.Services.Implementation
|
|||
query.DateTo);
|
||||
|
||||
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
|
||||
var ids = WorkOrderIdSet.ParseOrThrow(query.Ids);
|
||||
|
||||
var dataQuery = new WorkOrderAdvancedSearchQuery(
|
||||
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
|
||||
|
|
@ -77,7 +78,8 @@ namespace SeaHaven.Services.Implementation
|
|||
query.IncludeDateless,
|
||||
query.DatePreset,
|
||||
query.DateFrom,
|
||||
query.DateTo));
|
||||
query.DateTo),
|
||||
ids);
|
||||
|
||||
var result = await _searchDataService.SearchAsync(dataQuery);
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
|
|
|||
|
|
@ -31,18 +31,6 @@ namespace SeaHaven.Services.Implementation
|
|||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
|
||||
{
|
||||
var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType);
|
||||
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
|
||||
{
|
||||
var row = await _templateData.GetByIdAsync(id);
|
||||
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
|
||||
}
|
||||
|
||||
public async Task EnsureCanUploadCompletionDocAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
|
|
@ -125,49 +113,6 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
ValidateTemplateRequest(request);
|
||||
|
||||
var entity = new CompletionDocTemplate
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
ServiceKey = request.ServiceKey.Trim(),
|
||||
WorkOrderType = request.WorkOrderType,
|
||||
TemplateUrl = request.TemplateUrl.Trim(),
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
var created = await _templateData.CreateAsync(entity);
|
||||
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
||||
created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive));
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
ValidateTemplateRequest(request);
|
||||
|
||||
var entity = await _completionData.GetTrackedTemplateAsync(id, CancellationToken.None);
|
||||
if (entity == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.ServiceKey = request.ServiceKey.Trim();
|
||||
entity.WorkOrderType = request.WorkOrderType;
|
||||
entity.TemplateUrl = request.TemplateUrl.Trim();
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _templateData.UpdateAsync(entity);
|
||||
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
||||
entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive));
|
||||
}
|
||||
|
||||
public async Task DeleteTemplateAsync(int id)
|
||||
{
|
||||
if (!await _templateData.DeleteAsync(id))
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
||||
}
|
||||
|
||||
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
|
|
@ -185,21 +130,6 @@ namespace SeaHaven.Services.Implementation
|
|||
return workOrder;
|
||||
}
|
||||
|
||||
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TemplateUrl))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required.");
|
||||
|
||||
if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ParseRowVersion(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Completion document templates registry: authoring (title, extra safety note,
|
||||
/// ordered procedures), search, and safe deletion. Reads are open to every
|
||||
/// authenticated caller; writes require the matching team permission.
|
||||
/// </summary>
|
||||
public interface ICompletionDocTemplateService
|
||||
{
|
||||
Task<IReadOnlyList<CompletionDocTemplateDetailDto>> ListAsync(
|
||||
string? search,
|
||||
string? serviceKey,
|
||||
WorkOrderType? workOrderType,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<CompletionDocTemplateDetailDto?> GetAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
Task<CompletionDocTemplateLinkedWorkOrdersDto> GetLinkedWorkOrdersAsync(
|
||||
ClaimsPrincipal user,
|
||||
int id,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<CompletionDocTemplateDetailDto> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
CompletionDocTemplateCreateDto request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<CompletionDocTemplateDetailDto> UpdateAsync(
|
||||
ClaimsPrincipal user,
|
||||
int id,
|
||||
CompletionDocTemplateCreateDto request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using System.Security.Claims;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public enum SlaBreachAcknowledgementOutcome
|
||||
{
|
||||
Acknowledged,
|
||||
/// <summary>This breach was already acknowledged; nothing new is recorded.</summary>
|
||||
AlreadyAcknowledged,
|
||||
/// <summary>Not an SLA work order the caller's Notification Center covers (another account or dispatcher, closed, no SEV level).</summary>
|
||||
NotFound,
|
||||
/// <summary>The work order has not missed its response deadline.</summary>
|
||||
NotBreached
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledging a missed SEV response deadline from the Notification Center: records who and when in
|
||||
/// the work order's audit history, which is also what removes the breach row from the feed.
|
||||
/// </summary>
|
||||
public interface ISlaBreachAcknowledgementService
|
||||
{
|
||||
Task<SlaBreachAcknowledgementOutcome> AcknowledgeAsync(
|
||||
ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderCompletionService
|
||||
{
|
||||
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
|
||||
Task EnsureCanUploadCompletionDocAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
|
|
@ -20,8 +17,5 @@ namespace SeaHaven.Services.Interfaces
|
|||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
|
||||
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
|
||||
Task DeleteTemplateAsync(int id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
720
SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs
Normal file
720
SeaHavenIndustries.Tests/CompletionDocTemplateServiceTests.cs
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Data.SeaHavenIndustries.Migrations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations.Operations;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
// Completion document templates registry: authoring with ordered procedures,
|
||||
// search, permission-gated writes, tenant-scoped linked work orders, and a
|
||||
// delete that unlinks Services without dropping their document requirement.
|
||||
public class CompletionDocTemplateServiceTests
|
||||
{
|
||||
private const string AdminId = "admin-1";
|
||||
private const string SchedulerId = "scheduler-1";
|
||||
private const string DispatcherId = "dispatcher-1";
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
|
||||
SeedUser(context, SchedulerId, "Scheduler", "Sam", "Scheduler");
|
||||
SeedUser(context, DispatcherId, "Dispatcher", "Dee", "Dispatcher");
|
||||
context.SaveChanges();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedUser(ApplicationDbContext context, string userId, string role, string first, string last)
|
||||
{
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = userId,
|
||||
UserName = userId,
|
||||
NormalizedUserName = userId.ToUpperInvariant(),
|
||||
FirstName = first,
|
||||
LastName = last
|
||||
});
|
||||
var roleId = "role-" + role;
|
||||
if (context.Roles.Local.All(r => r.Id != roleId))
|
||||
context.Roles.Add(new IdentityRole { Id = roleId, Name = role, NormalizedName = role.ToUpperInvariant() });
|
||||
context.UserRoles.Add(new IdentityUserRole<string> { UserId = userId, RoleId = roleId });
|
||||
}
|
||||
|
||||
private static CompletionDocTemplateService NewService(ApplicationDbContext context) =>
|
||||
new(
|
||||
new CompletionDocTemplateDataService(context),
|
||||
new TeamPermissionOverrideDataService(context),
|
||||
new TeamPermissionPolicy(),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
private static ClaimsPrincipal Caller(string userId, string role, int? accountId = null)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, userId),
|
||||
new(ClaimTypes.Role, role)
|
||||
};
|
||||
claims.Add(accountId.HasValue
|
||||
? new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString())
|
||||
: new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Admin(int? accountId = null) => Caller(AdminId, "Admin", accountId);
|
||||
private static ClaimsPrincipal Scheduler() => Caller(SchedulerId, "Scheduler");
|
||||
private static ClaimsPrincipal Dispatcher() => Caller(DispatcherId, "Dispatcher");
|
||||
|
||||
private static CompletionDocTemplateCreateDto Body(
|
||||
string? name = "Backflow Test Report",
|
||||
string? extraSafetyNote = null,
|
||||
params (string? Name, string? Description)[] procedures) => new()
|
||||
{
|
||||
Name = name,
|
||||
ExtraSafetyNote = extraSafetyNote,
|
||||
Procedures = procedures
|
||||
.Select(p => new CompletionDocTemplateProcedureInputDto { Name = p.Name, Description = p.Description })
|
||||
.ToList()
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PersistsTitleNoteAndProceduresInSubmittedOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
|
||||
var created = await service.CreateAsync(
|
||||
Admin(),
|
||||
Body(" Backflow Test Report ", " Wear gloves ",
|
||||
("Backflow Procedure", "line 1\nline 2"),
|
||||
("Shutoff", "Close valve"),
|
||||
("Cleanup", "")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Backflow Test Report", created.Name);
|
||||
Assert.Equal("Wear gloves", created.ExtraSafetyNote);
|
||||
Assert.Equal(new[] { "Backflow Procedure", "Shutoff", "Cleanup" }, created.Procedures.Select(p => p.Name));
|
||||
Assert.Equal(new[] { 0, 1, 2 }, created.Procedures.Select(p => p.SortOrder));
|
||||
Assert.Equal("line 1\nline 2", created.Procedures[0].Description);
|
||||
Assert.All(created.Procedures, p => Assert.True(p.Id > 0));
|
||||
Assert.Equal("Jane Doe", created.CreatedByName);
|
||||
Assert.NotNull(created.CreatedAt);
|
||||
Assert.Equal(DateTimeKind.Utc, created.CreatedAt!.Value.Kind);
|
||||
Assert.Null(created.UpdatedAt);
|
||||
Assert.True(created.IsActive);
|
||||
Assert.Equal("", created.ServiceKey);
|
||||
Assert.Equal("", created.TemplateUrl);
|
||||
|
||||
var fetched = await service.GetAsync(created.Id, CancellationToken.None);
|
||||
Assert.Equal(created.Procedures.Select(p => p.Id), fetched!.Procedures.Select(p => p.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithZeroProceduresAndNoNote_Succeeds()
|
||||
{
|
||||
using var context = NewContext();
|
||||
|
||||
var created = await NewService(context).CreateAsync(
|
||||
Admin(), new CompletionDocTemplateCreateDto { Name = "Empty" }, CancellationToken.None);
|
||||
|
||||
Assert.Empty(created.Procedures);
|
||||
Assert.Null(created.ExtraSafetyNote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AllowsBlankProcedureNameAndDescription()
|
||||
{
|
||||
using var context = NewContext();
|
||||
|
||||
var created = await NewService(context).CreateAsync(
|
||||
Admin(), Body("Placeholder", null, (null, null), (" ", "")), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, created.Procedures.Count);
|
||||
Assert.All(created.Procedures, p => Assert.Equal("", p.Name));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateAndUpdate_WithoutTitle_ThrowTitleRequired(string? name)
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
var existing = await service.CreateAsync(Admin(), Body(), CancellationToken.None);
|
||||
|
||||
var onCreate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(Admin(), Body(name), CancellationToken.None));
|
||||
var onUpdate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateAsync(Admin(), existing.Id, Body(name), CancellationToken.None));
|
||||
|
||||
Assert.Equal("TitleRequired", onCreate.Code);
|
||||
Assert.Equal("Document Title is required.", onCreate.Message);
|
||||
Assert.Equal("TitleRequired", onUpdate.Code);
|
||||
Assert.Single(context.CompletionDocTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RejectsOverlongExtraSafetyNoteAndProcedureName()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
|
||||
var note = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(Admin(), Body("T", new string('n', 2001)), CancellationToken.None));
|
||||
var procedure = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(Admin(), Body("T", null, (new string('p', 201), "d")), CancellationToken.None));
|
||||
|
||||
Assert.Equal("InvalidValue", note.Code);
|
||||
Assert.Equal("InvalidValue", procedure.Code);
|
||||
Assert.Empty(context.CompletionDocTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ReplacesWholeProcedureListAndStampsUpdatedAt()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
var created = await service.CreateAsync(
|
||||
Admin(), Body("T", "Note", ("A", "a"), ("B", "b"), ("C", "c")), CancellationToken.None);
|
||||
|
||||
var updated = await service.UpdateAsync(
|
||||
Admin(), created.Id, Body("T2", "Note", ("C", "c2"), ("A", "a2")), CancellationToken.None);
|
||||
|
||||
Assert.Equal("T2", updated.Name);
|
||||
Assert.Equal(new[] { "C", "A" }, updated.Procedures.Select(p => p.Name));
|
||||
Assert.Equal(new[] { 0, 1 }, updated.Procedures.Select(p => p.SortOrder));
|
||||
Assert.Equal(2, context.CompletionDocTemplateProcedures.Count());
|
||||
Assert.NotNull(updated.UpdatedAt);
|
||||
Assert.Equal("Jane Doe", updated.CreatedByName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NullNoteClearsIt_EmptyProceduresClearThem_NullProceduresKeepThem()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
var created = await service.CreateAsync(Admin(), Body("T", "Note", ("A", "a")), CancellationToken.None);
|
||||
|
||||
var kept = await service.UpdateAsync(
|
||||
Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = null }, CancellationToken.None);
|
||||
Assert.Null(kept.ExtraSafetyNote);
|
||||
Assert.Equal(new[] { "A" }, kept.Procedures.Select(p => p.Name));
|
||||
|
||||
var cleared = await service.UpdateAsync(
|
||||
Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = new() }, CancellationToken.None);
|
||||
Assert.Empty(cleared.Procedures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OmittedLegacyFieldsKeepStoredValues()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate
|
||||
{
|
||||
Id = 7,
|
||||
Name = "HVAC PM Completion",
|
||||
ServiceKey = "HVAC PM",
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
TemplateUrl = "https://example.com/hvac.pdf",
|
||||
IsActive = true
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var updated = await NewService(context).UpdateAsync(
|
||||
Admin(), 7, Body("HVAC PM Completion v2", "Mask"), CancellationToken.None);
|
||||
|
||||
Assert.Equal("HVAC PM", updated.ServiceKey);
|
||||
Assert.Equal(WorkOrderType.PM, updated.WorkOrderType);
|
||||
Assert.Equal("https://example.com/hvac.pdf", updated.TemplateUrl);
|
||||
Assert.True(updated.IsActive);
|
||||
Assert.Null(updated.CreatedByName);
|
||||
}
|
||||
|
||||
// Bodies are bound by System.Text.Json with the web defaults, as MVC does.
|
||||
private static CompletionDocTemplateCreateDto JsonBody(string json) =>
|
||||
System.Text.Json.JsonSerializer.Deserialize<CompletionDocTemplateCreateDto>(
|
||||
json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WorkOrderTypeExplicitNullClears_OmittedKeeps_ValueSets()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate
|
||||
{
|
||||
Id = 8,
|
||||
Name = "HVAC PM Completion",
|
||||
ServiceKey = "HVAC",
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
TemplateUrl = "",
|
||||
IsActive = true
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var omitted = JsonBody("""{"name":"HVAC Completion"}""");
|
||||
Assert.False(omitted.WorkOrderTypeSpecified);
|
||||
var kept = await service.UpdateAsync(Admin(), 8, omitted, CancellationToken.None);
|
||||
Assert.Equal(WorkOrderType.PM, kept.WorkOrderType);
|
||||
|
||||
var cleared = await service.UpdateAsync(
|
||||
Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":null}"""), CancellationToken.None);
|
||||
Assert.Null(cleared.WorkOrderType);
|
||||
Assert.Null((await context.CompletionDocTemplates.AsNoTracking().SingleAsync(t => t.Id == 8)).WorkOrderType);
|
||||
|
||||
var set = await service.UpdateAsync(
|
||||
Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":6}"""), CancellationToken.None);
|
||||
Assert.Equal(WorkOrderType.Reactive, set.WorkOrderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_MissingOrDeletedTemplate_ThrowsNotFound()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 3, Name = "Gone", IsDeleted = true });
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var missing = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateAsync(Admin(), 999, Body(), CancellationToken.None));
|
||||
var deleted = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateAsync(Admin(), 3, Body(), CancellationToken.None));
|
||||
|
||||
Assert.Equal("NotFound", missing.Code);
|
||||
Assert.Equal("NotFound", deleted.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_SearchIsPartialCaseInsensitiveAndOrderedByName()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
foreach (var name in new[] { "Roof Inspection", "Backflow Test Report", "Annual BACKFLOW check", "HVAC PM" })
|
||||
await service.CreateAsync(Admin(), Body(name), CancellationToken.None);
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Name = "Backflow deleted", IsDeleted = true });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var matches = await service.ListAsync("backFLOW", null, null, CancellationToken.None);
|
||||
var all = await service.ListAsync(null, null, null, CancellationToken.None);
|
||||
var none = await service.ListAsync("zzz", null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "Annual BACKFLOW check", "Backflow Test Report" }, matches.Select(t => t.Name));
|
||||
Assert.Equal(
|
||||
new[] { "Annual BACKFLOW check", "Backflow Test Report", "HVAC PM", "Roof Inspection" },
|
||||
all.Select(t => t.Name));
|
||||
Assert.Empty(none);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_KeepsLegacyServiceKeyAndWorkOrderTypeFilters()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.CompletionDocTemplates.AddRange(
|
||||
new CompletionDocTemplate { Name = "HVAC PM", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.PM },
|
||||
new CompletionDocTemplate { Name = "HVAC Any", ServiceKey = "HVAC" },
|
||||
new CompletionDocTemplate { Name = "HVAC Reactive", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.Reactive },
|
||||
new CompletionDocTemplate { Name = "Plumbing", ServiceKey = "Plumbing" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var rows = await NewService(context).ListAsync(null, "HVAC", WorkOrderType.PM, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "HVAC Any", "HVAC PM" }, rows.Select(t => t.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writes_FollowTeamPermissions()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
|
||||
var byScheduler = await service.CreateAsync(Scheduler(), Body("Scheduler template"), CancellationToken.None);
|
||||
await service.UpdateAsync(Scheduler(), byScheduler.Id, Body("Scheduler edit"), CancellationToken.None);
|
||||
Assert.Equal("Sam Scheduler", byScheduler.CreatedByName);
|
||||
|
||||
var schedulerDelete = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.DeleteAsync(Scheduler(), byScheduler.Id, CancellationToken.None));
|
||||
var dispatcherCreate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(Dispatcher(), Body("Nope"), CancellationToken.None));
|
||||
var dispatcherEdit = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateAsync(Dispatcher(), byScheduler.Id, Body("Nope"), CancellationToken.None));
|
||||
var anonymous = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(new ClaimsPrincipal(new ClaimsIdentity()), Body("Nope"), CancellationToken.None));
|
||||
|
||||
Assert.All(new[] { schedulerDelete, dispatcherCreate, dispatcherEdit, anonymous },
|
||||
ex => Assert.Equal("Forbidden", ex.Code));
|
||||
Assert.Equal("Scheduler edit", context.CompletionDocTemplates.Single().Name);
|
||||
Assert.NotEqual(true, context.CompletionDocTemplates.Single().IsDeleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writes_HonorPerPersonOverrides()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.UserPermissionOverrides.Add(new UserPermissionOverride
|
||||
{
|
||||
UserId = SchedulerId,
|
||||
PermissionKey = "deleteCompletionDocTemplates",
|
||||
State = UserPermissionState.Allow
|
||||
});
|
||||
context.UserPermissionOverrides.Add(new UserPermissionOverride
|
||||
{
|
||||
UserId = SchedulerId,
|
||||
PermissionKey = "createCompletionDocTemplates",
|
||||
State = UserPermissionState.Deny
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
var template = await service.CreateAsync(Admin(), Body(), CancellationToken.None);
|
||||
|
||||
var denied = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(Scheduler(), Body("Denied"), CancellationToken.None));
|
||||
await service.DeleteAsync(Scheduler(), template.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Forbidden", denied.Code);
|
||||
Assert.True(context.CompletionDocTemplates.Single().IsDeleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinkedWorkOrders_CountsOnlyOpenWorkOrdersOfLinkedServicesInCallerTenant()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var template = new CompletionDocTemplate { Id = 10, Name = "Backflow" };
|
||||
var other = new CompletionDocTemplate { Id = 11, Name = "Other" };
|
||||
context.CompletionDocTemplates.AddRange(template, other);
|
||||
context.Services.AddRange(
|
||||
new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
|
||||
new Service { Id = 2, Name = "Backflow 2", NormalizedName = "BACKFLOW 2", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
|
||||
new Service { Id = 3, Name = "Roof", NormalizedName = "ROOF", RequiresCompletionDocument = true, CompletionDocTemplateId = 11 });
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
|
||||
new WorkOrder { Id = 102, ServiceId = 2, AccountId = 1, LifecycleStatus = null },
|
||||
new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed },
|
||||
new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled },
|
||||
new WorkOrder { Id = 105, ServiceId = 3, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
|
||||
new WorkOrder { Id = 106, ServiceId = null, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
|
||||
new WorkOrder { Id = 107, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, IsDeleted = true },
|
||||
new WorkOrder { Id = 108, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, istemplate = true },
|
||||
new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.InProgress });
|
||||
context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var tenantOne = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 1), 10, CancellationToken.None);
|
||||
var tenantTwo = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 2), 10, CancellationToken.None);
|
||||
var orgWide = await service.GetLinkedWorkOrdersAsync(Admin(), 10, CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, tenantOne.Count);
|
||||
Assert.Equal(new[] { 101, 102, 111 }, tenantOne.WorkOrderIds);
|
||||
Assert.Equal(new[] { 205 }, tenantTwo.WorkOrderIds);
|
||||
Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide.WorkOrderIds);
|
||||
Assert.Equal(4, orgWide.Count);
|
||||
}
|
||||
|
||||
// Legacy rows carry no LifecycleStatus; their legacy status text decides whether they are open.
|
||||
private static WorkOrder[] LegacyStatusWorkOrders(int serviceId, int accountId) => new[]
|
||||
{
|
||||
new WorkOrder { Id = 109, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "Completed" },
|
||||
new WorkOrder { Id = 110, ServiceId = serviceId, AccountId = accountId, Status = " Cancelled " },
|
||||
new WorkOrder { Id = 111, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "On Hold" }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task LinkedWorkOrders_UnknownTemplateOrMissingScope_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
var noScope = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, DispatcherId), new Claim(ClaimTypes.Role, "Dispatcher") },
|
||||
authenticationType: "test"));
|
||||
|
||||
var missing = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.GetLinkedWorkOrdersAsync(Admin(), 999, CancellationToken.None));
|
||||
var forbidden = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.GetLinkedWorkOrdersAsync(noScope, 10, CancellationToken.None));
|
||||
|
||||
Assert.Equal("NotFound", missing.Code);
|
||||
Assert.Equal("Forbidden", forbidden.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ClearsServiceLinksKeepsDocumentRequirementAndHidesTemplate()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
|
||||
context.Services.AddRange(
|
||||
new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
|
||||
new Service { Id = 2, Name = "Unrelated", NormalizedName = "UNRELATED", RequiresCompletionDocument = true, CompletionDocTemplateId = null });
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
await service.DeleteAsync(Admin(), 10, CancellationToken.None);
|
||||
|
||||
var linked = context.Services.Single(s => s.Id == 1);
|
||||
Assert.Null(linked.CompletionDocTemplateId);
|
||||
Assert.True(linked.RequiresCompletionDocument);
|
||||
Assert.True(context.Services.Single(s => s.Id == 2).RequiresCompletionDocument);
|
||||
var template = context.CompletionDocTemplates.Single();
|
||||
Assert.True(template.IsDeleted);
|
||||
Assert.Equal(AdminId, template.DeleterUserId);
|
||||
Assert.Null(await service.GetAsync(10, CancellationToken.None));
|
||||
Assert.Empty(await service.ListAsync(null, null, null, CancellationToken.None));
|
||||
|
||||
var again = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.DeleteAsync(Admin(), 10, CancellationToken.None));
|
||||
Assert.Equal("NotFound", again.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_ForwardCancellation()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var service = NewService(context);
|
||||
await service.CreateAsync(Admin(), Body(), CancellationToken.None);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
service.ListAsync(null, null, null, cts.Token));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
service.CreateAsync(Admin(), Body("Cancelled"), cts.Token));
|
||||
Assert.Single(context.CompletionDocTemplates);
|
||||
}
|
||||
|
||||
// Relational evidence (SQLite enforces foreign keys; the in-memory provider does not).
|
||||
|
||||
[Fact]
|
||||
public async Task Relational_DeleteOfLinkedTemplateSucceedsAndUnlinksService()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var context = await NewSqliteContextAsync(connection);
|
||||
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
|
||||
var template = new CompletionDocTemplate { Name = "Backflow" };
|
||||
context.CompletionDocTemplates.Add(template);
|
||||
await context.SaveChangesAsync();
|
||||
context.Services.Add(new Service
|
||||
{
|
||||
Name = "Backflow",
|
||||
NormalizedName = "BACKFLOW",
|
||||
RequiresCompletionDocument = true,
|
||||
CompletionDocTemplateId = template.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
await service.DeleteAsync(Admin(), template.Id, CancellationToken.None);
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
var stored = await context.Services.SingleAsync();
|
||||
Assert.Null(stored.CompletionDocTemplateId);
|
||||
Assert.True(stored.RequiresCompletionDocument);
|
||||
Assert.True((await context.CompletionDocTemplates.SingleAsync()).IsDeleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relational_LinkedWorkOrdersExcludeTerminalAndLegacyTerminalRows()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var context = await NewSqliteContextAsync(connection);
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1);
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "Other Corp");
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
|
||||
context.Services.Add(new Service
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Backflow",
|
||||
NormalizedName = "BACKFLOW",
|
||||
RequiresCompletionDocument = true,
|
||||
CompletionDocTemplateId = 10
|
||||
});
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
|
||||
new WorkOrder { Id = 102, ServiceId = 1, AccountId = 1 },
|
||||
new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed },
|
||||
new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled },
|
||||
new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.OnSite });
|
||||
context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1));
|
||||
await context.SaveChangesAsync();
|
||||
var data = new CompletionDocTemplateDataService(context);
|
||||
|
||||
var tenantOne = await data.GetOpenLinkedWorkOrderIdsAsync(10, 1, CancellationToken.None);
|
||||
var orgWide = await data.GetOpenLinkedWorkOrderIdsAsync(10, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { 101, 102, 111 }, tenantOne);
|
||||
Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relational_UpdateReplacesProceduresInOrder()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var context = await NewSqliteContextAsync(connection);
|
||||
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
var created = await service.CreateAsync(
|
||||
Admin(), Body("T", null, ("A", "a"), ("B", "b")), CancellationToken.None);
|
||||
|
||||
context.ChangeTracker.Clear();
|
||||
var updated = await service.UpdateAsync(
|
||||
Admin(), created.Id, Body("T", null, ("B", "b"), ("C", "c"), ("A", "a")), CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "B", "C", "A" }, updated.Procedures.Select(p => p.Name));
|
||||
Assert.Equal("Jane Doe", updated.CreatedByName);
|
||||
context.ChangeTracker.Clear();
|
||||
Assert.Equal(3, await context.CompletionDocTemplateProcedures.CountAsync());
|
||||
}
|
||||
|
||||
private static async Task<ApplicationDbContext> NewSqliteContextAsync(SqliteConnection connection)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
var context = new SqliteTemplateTestDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
return context;
|
||||
}
|
||||
|
||||
private sealed class SqliteTemplateTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteTemplateTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
// SQL Server filtered index syntax is invalid on SQLite.
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
// SQLite has no rowversion type; treat as plain nullable blobs.
|
||||
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
|
||||
{
|
||||
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP contract of the template endpoints.
|
||||
|
||||
private static WorkOrderCompletionController NewController(ApplicationDbContext context, ClaimsPrincipal user) =>
|
||||
new(null!, NewService(context), null!)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = user } }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_MapsTitleRequiredTo422AndForbiddenTo403()
|
||||
{
|
||||
using var context = NewContext();
|
||||
|
||||
var blank = await NewController(context, Admin())
|
||||
.CreateCompletionTemplate(Body(" "), CancellationToken.None);
|
||||
var denied = await NewController(context, Dispatcher())
|
||||
.CreateCompletionTemplate(Body("Allowed title"), CancellationToken.None);
|
||||
var missing = await NewController(context, Admin())
|
||||
.DeleteCompletionTemplate(999, CancellationToken.None);
|
||||
|
||||
var unprocessable = Assert.IsType<UnprocessableEntityObjectResult>(blank);
|
||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(unprocessable.Value);
|
||||
Assert.Equal("TitleRequired", error.Code);
|
||||
Assert.Equal("Document Title is required.", error.Message);
|
||||
var forbidden = Assert.IsType<ObjectResult>(denied);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
|
||||
Assert.IsType<NotFoundObjectResult>(missing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_LinkedWorkOrdersAndDelete_ReturnContractShapes()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
|
||||
context.Services.Add(new Service { Id = 1, Name = "B", NormalizedName = "B", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 });
|
||||
context.workOrders.Add(new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||
await context.SaveChangesAsync();
|
||||
var controller = NewController(context, Admin());
|
||||
|
||||
var linked = await controller.GetCompletionTemplateLinkedWorkOrders(10, CancellationToken.None);
|
||||
var deleted = await controller.DeleteCompletionTemplate(10, CancellationToken.None);
|
||||
|
||||
var body = Assert.IsType<CompletionDocTemplateLinkedWorkOrdersDto>(Assert.IsType<OkObjectResult>(linked).Value);
|
||||
Assert.Equal(1, body.Count);
|
||||
Assert.Equal(new[] { 101 }, body.WorkOrderIds);
|
||||
Assert.IsType<NoContentResult>(deleted);
|
||||
}
|
||||
|
||||
// Migration shape (G6 evidence): additive only.
|
||||
|
||||
[Fact]
|
||||
public void Migration_Up_AddsBoundedNoteAndCascadingProcedureTableOnly()
|
||||
{
|
||||
var operations = BuildOperations("Up");
|
||||
|
||||
var column = Assert.Single(operations.OfType<AddColumnOperation>());
|
||||
Assert.Equal("CompletionDocTemplates", column.Table);
|
||||
Assert.Equal("ExtraSafetyNote", column.Name);
|
||||
Assert.True(column.IsNullable);
|
||||
Assert.Equal(2000, column.MaxLength);
|
||||
|
||||
var table = Assert.Single(operations.OfType<CreateTableOperation>());
|
||||
Assert.Equal("CompletionDocTemplateProcedures", table.Name);
|
||||
Assert.Contains(table.Columns, c => c.Name == "Name" && c.MaxLength == 200 && !c.IsNullable);
|
||||
var fk = Assert.Single(table.ForeignKeys);
|
||||
Assert.Equal("CompletionDocTemplates", fk.PrincipalTable);
|
||||
Assert.Equal(ReferentialAction.Cascade, fk.OnDelete);
|
||||
|
||||
var index = Assert.Single(operations.OfType<CreateIndexOperation>());
|
||||
Assert.Equal(new[] { "CompletionDocTemplateId", "SortOrder" }, index.Columns);
|
||||
|
||||
Assert.Equal(3, operations.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Migration_Down_DropsOnlyWhatUpAdded()
|
||||
{
|
||||
var operations = BuildOperations("Down");
|
||||
|
||||
Assert.Equal("CompletionDocTemplateProcedures", Assert.Single(operations.OfType<DropTableOperation>()).Name);
|
||||
var dropped = Assert.Single(operations.OfType<DropColumnOperation>());
|
||||
Assert.Equal(("CompletionDocTemplates", "ExtraSafetyNote"), (dropped.Table, dropped.Name));
|
||||
Assert.Equal(2, operations.Count);
|
||||
}
|
||||
|
||||
private static List<MigrationOperation> BuildOperations(string direction)
|
||||
{
|
||||
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
|
||||
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"Migration.{direction} not found.");
|
||||
operation.Invoke(new CompletionDocTemplateProcedures(), [builder]);
|
||||
return builder.Operations;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue