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