Merge pull request #188 from Sea-Haven-Industries/feat/ab/sh-295-sla-alerts

feat(notifications): SEV response-window alerts and breach acknowledgement
This commit is contained in:
Alexandre Brandizzi 2026-09-25 14:31:53 +00:00 • committed by GitHub
commit ed5c75223e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 853 additions and 18 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

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

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

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

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

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

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

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