From 14c8e46dd0a7a1b779d9712556af6ef763debdd6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:16:21 -0300 Subject: [PATCH] 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 "), 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. --- .../NotificationFeedServiceTests.cs | 6 +- .../NotificationSlaTests.cs | 402 ++++++++++++++++++ .../Controllers/NotificationsController.cs | 42 +- .../NotificationFeedDataService.cs | 85 ++++ .../INotificationFeedDataService.cs | 17 + .../Models/NotificationFeedReadModels.cs | 35 ++ .../DTOs/NotificationFeedDTOs.cs | 17 + .../Helpers/NotificationAudience.cs | 27 ++ .../Helpers/SlaResponseWindows.cs | 55 +++ .../Implementation/NotificationFeedService.cs | 84 +++- .../SlaBreachAcknowledgementService.cs | 76 ++++ .../ISlaBreachAcknowledgementService.cs | 25 ++ 12 files changed, 853 insertions(+), 18 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs create mode 100644 SeaHaven.Services/Helpers/NotificationAudience.cs create mode 100644 SeaHaven.Services/Helpers/SlaResponseWindows.cs create mode 100644 SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs create mode 100644 SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs diff --git a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs index 9e8910e..6f483f1 100644 --- a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs @@ -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()) { 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()) { ControllerContext = new ControllerContext { diff --git a/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs b/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs new file mode 100644 index 0000000..2df8a3f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs @@ -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; + +/// +/// 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). +/// +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() + .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 ItemIds(NotificationFeedDto feed) + => SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty(); + + [Fact] + public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge() + { + SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes) + .Should().Equal(new Dictionary + { + [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()).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() + .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(); + (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/NotificationsController.cs b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs index 5eff188..f091119 100644 --- a/Api.SeaHavenIndustries/Controllers/NotificationsController.cs +++ b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs @@ -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; + } + + /// + /// 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. + /// + [HttpPost("sla/{workOrderId:int}/acknowledge")] + public async Task 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] diff --git a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs index 5b5748f..f3b0986 100644 --- a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs +++ b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs @@ -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 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() + : 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 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 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 CreatedAtOrBefore(IQueryable 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 CreatedAfter(IQueryable 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 ProjectSla(IQueryable 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 ScopedWorkOrders(int? accountId) { var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); diff --git a/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs index 1976764..b1aed65 100644 --- a/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs +++ b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs @@ -48,5 +48,22 @@ namespace SeaHaven.DataServices.Interfaces /// Task> GetUpliftDecisionsAsync( NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken); + + /// + /// Open Reactive/Emergency work orders with a SEV 1–5 level, split by creation time: at risk when + /// created at or before but after , breached when + /// created at or before and not yet acknowledged for their current level. + /// Breaches are the newest; the at-risk set is bounded by the longest window. + /// + Task GetSlaCandidatesAsync( + NotificationFeedScope scope, + NotificationSlaCutoffs atRisk, + NotificationSlaCutoffs breached, + int breachedLimit, + CancellationToken cancellationToken); + + /// The work order as an SLA candidate inside , or null when it is not one. + Task GetSlaCandidateAsync( + NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs index d404be2..53165ec 100644 --- a/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs +++ b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs @@ -64,3 +64,38 @@ public sealed class NotificationUpliftDecisionCandidate public string? DecisionNote { get; init; } public DateTime DecidedAt { get; init; } } + +/// +/// 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. +/// +public sealed record NotificationSlaCutoffs(DateTime Sev1, DateTime Sev2, DateTime Sev3, DateTime Sev4, DateTime Sev5); + +/// +/// 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. +/// +public static class SlaBreachAcknowledgementAudit +{ + public const string FieldName = "SlaBreachAcknowledged"; +} + +/// An open Reactive/Emergency work order with a SEV 1–5 level and a creation time. +public sealed class NotificationSlaCandidate +{ + public int Id { get; init; } + public string? Number { get; init; } + public string Severity { get; init; } = ""; + public DateTime CreatedAt { get; init; } + /// A breach of the work order's current severity has already been acknowledged. + public bool BreachAcknowledged { get; init; } +} + +public sealed class NotificationSlaCandidates +{ + /// Every work order past half its window but not past the whole window. + public IReadOnlyList AtRisk { get; init; } = Array.Empty(); + /// Unacknowledged breaches: the newest slice, plus the unbounded total. + public IReadOnlyList Breached { get; init; } = Array.Empty(); + public int BreachedTotal { get; init; } +} diff --git a/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs index a2ab7e9..10d8146 100644 --- a/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs +++ b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs @@ -126,9 +126,26 @@ namespace SeaHaven.Services.DTOs public IReadOnlyList Items { get; init; } = Array.Empty(); } + /// + /// 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. + /// + public sealed class NotificationSlaWorkOrderDto + { + public int Id { get; init; } + public string? Number { get; init; } + public int Severity { get; init; } + /// The work order's creation time; the response clock never starts at the scheduled date. + 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 Sections { get; init; } = Array.Empty(); + /// Every at-risk work order in the feed's scope, oldest first; not capped like section items. + public IReadOnlyList SlaAtRisk { get; init; } = Array.Empty(); } } diff --git a/SeaHaven.Services/Helpers/NotificationAudience.cs b/SeaHaven.Services/Helpers/NotificationAudience.cs new file mode 100644 index 0000000..e35d76b --- /dev/null +++ b/SeaHaven.Services/Helpers/NotificationAudience.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + /// Whose work orders the Notification Center covers for the signed-in user. + 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."); + } + } +} diff --git a/SeaHaven.Services/Helpers/SlaResponseWindows.cs b/SeaHaven.Services/Helpers/SlaResponseWindows.cs new file mode 100644 index 0000000..35ab817 --- /dev/null +++ b/SeaHaven.Services/Helpers/SlaResponseWindows.cs @@ -0,0 +1,55 @@ +namespace SeaHaven.Services.Helpers +{ + public enum SlaState + { + OnTrack, + /// At least half of the response window has elapsed. + AtRisk, + /// The whole response window has elapsed: the response deadline was missed. + Breached + } + + /// Where one work order stands against its SEV response window. + public sealed record SlaClock(int Severity, DateTime StartedAt, DateTime DeadlineAt, int PercentElapsed, SlaState State); + + /// + /// 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. + /// + public static class SlaResponseWindows + { + public static readonly IReadOnlyDictionary Respond = new Dictionary + { + [1] = TimeSpan.FromHours(2), + [2] = TimeSpan.FromHours(4), + [3] = TimeSpan.FromHours(8), + [4] = TimeSpan.FromHours(24), + [5] = TimeSpan.FromHours(72) + }; + + /// The latest creation time at which a work order of this level has used half its window. + public static DateTime AtRiskCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity] / 2; + + /// The latest creation time at which a work order of this level has missed its deadline. + public static DateTime BreachedCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity]; + + /// Null when the severity is not a SEV 1–5 level. + 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); + } + } +} diff --git a/SeaHaven.Services/Implementation/NotificationFeedService.cs b/SeaHaven.Services/Implementation/NotificationFeedService.cs index ff12e45..9b08cc9 100644 --- a/SeaHaven.Services/Implementation/NotificationFeedService.cs +++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs @@ -41,11 +41,13 @@ namespace SeaHaven.Services.Implementation public async Task 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 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) Section, IReadOnlyList 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() + .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 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 diff --git a/SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs b/SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs new file mode 100644 index 0000000..412317e --- /dev/null +++ b/SeaHaven.Services/Implementation/SlaBreachAcknowledgementService.cs @@ -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 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; + } + } +} diff --git a/SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs b/SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs new file mode 100644 index 0000000..e728a6c --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISlaBreachAcknowledgementService.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +namespace SeaHaven.Services.Interfaces +{ + public enum SlaBreachAcknowledgementOutcome + { + Acknowledged, + /// This breach was already acknowledged; nothing new is recorded. + AlreadyAcknowledged, + /// Not an SLA work order the caller's Notification Center covers (another account or dispatcher, closed, no SEV level). + NotFound, + /// The work order has not missed its response deadline. + NotBreached + } + + /// + /// 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. + /// + public interface ISlaBreachAcknowledgementService + { + Task AcknowledgeAsync( + ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken); + } +}