Compare commits

...

8 commits

Author SHA1 Message Date
Alexandre Brandizzi
c8073123e3
Merge pull request #183 from Sea-Haven-Industries/feat/ab/wo-ids-filter
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
feat(workorders): filter board to an exact work-order id set
2026-09-25 14:55:26 +00:00
Alexandre Brandizzi
f9cdbaa06b
Merge pull request #185 from Sea-Haven-Industries/feat/ab/sh-313-completion-templates-api
feat(completion-templates): template content, search, linked work orders and safe delete
2026-09-25 14:49:35 +00:00
Alexandre Brandizzi
a224f883bc fix(completion-templates): let PUT clear workOrderType with an explicit null
UpdateAsync only applied WorkOrderType when it had a value, so once a
template was restricted to one work order type no request could make it
trade-generic again. The DTO now records whether workOrderType was present
in the body: omitting it keeps the stored value, an explicit null clears
it, and a concrete value sets it. The templates page echoes the stored
legacy fields on PUT, so its behaviour is unchanged.
2026-09-25 11:40:49 -03:00
Alexandre Brandizzi
ed5c75223e
Merge pull request #188 from Sea-Haven-Industries/feat/ab/sh-295-sla-alerts
feat(notifications): SEV response-window alerts and breach acknowledgement
2026-09-25 14:31:53 +00:00
Alexandre Brandizzi
cd23ad5b68 fix(completion-templates): read legacy status when counting open linked work orders
Work orders without a LifecycleStatus are open or closed according to
their legacy status text. The linked work-order count now goes through
the shared board status filter, so a legacy completed or cancelled row
is no longer reported as depending on the template.
2026-09-25 11:19:59 -03:00
Alexandre Brandizzi
14c8e46dd0 feat(notifications): SEV response-window alerts and breach acknowledgement
Reactive/Emergency work orders with a SEV 1-5 level are timed from their
creation against the SEV Respond deadline (2/4/8/24/72 hours, one backend
table). From 50% they are at risk: a dismissable High row in the "SLA at
Risk" section and an entry in the feed's slaAtRisk set with the server
clock (start, deadline, percent) for the banner and toast. From 100% they
are a Critical acknowledge row that only acknowledging removes.

POST /api/notifications/sla/{id}/acknowledge records who and when as a
work-order audit entry ("SLA breach acknowledged by <name>"), scoped to the
caller's feed audience: 404 outside it, 409 before the deadline, 204 when
recorded or already recorded. A later severity change is a new breach.
2026-09-25 11:16:21 -03:00
Alexandre Brandizzi
7c097c2750 feat(completion-templates): author templates with safety note and ordered procedures
Adds an extra safety note and an ordered procedure list to completion
document templates, name search, creator and last-updated audit fields,
a tenant-scoped count of open work orders that depend on a template, and
a delete that unlinks Services while they keep requiring a document.
Writes are gated by the create/edit/delete completion template team
permissions instead of the Admin role.
2026-09-25 11:00:22 -03:00
Alexandre Brandizzi
3019e71093 feat(workorders): filter board search to an exact work-order id set
GET /board/search accepts ids=1,2,3 (positive ints, deduplicated, at most
200). When present the result is exactly those work orders inside the
caller's tenant and base scope; date, status, dispatcher, facet and text
filters are ignored so none of them can hide a listed work order.
Malformed or oversized lists are a 400.
2026-09-25 10:56:47 -03:00
36 changed files with 6879 additions and 183 deletions

View file

@ -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
{

View 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);
}
}

View 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) }
}
};
}
}

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 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",

View file

@ -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]

View file

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

View file

@ -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; }

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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");

View file

@ -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; } = "";
}
}

View file

@ -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);
}

View file

@ -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());

View file

@ -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)

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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,

View file

@ -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; }
}

View file

@ -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>();
}
}

View file

@ -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; }
}

View file

@ -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

View 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.");
}
}
}

View 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);
}
}
}

View 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;
}
}
}

View 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;
}
}

View file

@ -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

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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))

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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;
}
}