mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 19:32:10 +00:00
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:
commit
ed5c75223e
12 changed files with 853 additions and 18 deletions
|
|
@ -7,11 +7,13 @@ using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -546,7 +548,7 @@ public class NotificationFeedServiceTests
|
||||||
public async Task Controller_MapsForbiddenScopeTo403()
|
public async Task Controller_MapsForbiddenScopeTo403()
|
||||||
{
|
{
|
||||||
using var context = NewContext();
|
using var context = NewContext();
|
||||||
var controller = new NotificationsController(NewService(context))
|
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext
|
ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|
@ -565,7 +567,7 @@ public class NotificationFeedServiceTests
|
||||||
using var context = NewContext();
|
using var context = NewContext();
|
||||||
context.workOrders.Add(Open(1));
|
context.workOrders.Add(Open(1));
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
var controller = new NotificationsController(NewService(context))
|
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext
|
ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|
|
||||||
402
Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs
Normal file
402
Api.SeaHavenIndustries.Tests/NotificationSlaTests.cs
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SEV response-window alerts in the Notification Center: at risk from 50% of the window (banner set and a
|
||||||
|
/// dismissable row), breached from 100% (an acknowledge row that only acknowledging removes).
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationSlaTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private sealed class FixedTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => new(Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderAccountResolver Resolver(ApplicationDbContext context)
|
||||||
|
=> new(new AccountDataService(context), new LocationDataService(context));
|
||||||
|
|
||||||
|
private static NotificationFeedService NewFeed(ApplicationDbContext context)
|
||||||
|
=> new(
|
||||||
|
new NotificationFeedDataService(context),
|
||||||
|
new VendorOperationsDataService(context, new DispatchDataService(context)),
|
||||||
|
Resolver(context),
|
||||||
|
new FixedTimeProvider());
|
||||||
|
|
||||||
|
private static SlaBreachAcknowledgementService NewAcknowledgement(ApplicationDbContext context)
|
||||||
|
=> new(
|
||||||
|
new NotificationFeedDataService(context),
|
||||||
|
new WorkOrderAuditDataService(context),
|
||||||
|
new UserDataService(context),
|
||||||
|
Resolver(context),
|
||||||
|
new FixedTimeProvider());
|
||||||
|
|
||||||
|
private static ClaimsPrincipal User(int accountId, string role, string userId = "disp-1")
|
||||||
|
=> new(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||||
|
new Claim(ClaimTypes.Role, role)
|
||||||
|
}, "test"));
|
||||||
|
|
||||||
|
private static WorkOrder Sla(
|
||||||
|
int id,
|
||||||
|
TimeSpan age,
|
||||||
|
string? severity = "1",
|
||||||
|
WorkOrderType? type = WorkOrderType.Reactive,
|
||||||
|
int accountId = 1,
|
||||||
|
string? assignTo = "disp-1",
|
||||||
|
DateTime? scheduled = null,
|
||||||
|
LifecycleStatus status = LifecycleStatus.Scheduled)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
AccountId = accountId,
|
||||||
|
AssignTo = assignTo,
|
||||||
|
InternalWONumber = $"{1000 + id}",
|
||||||
|
WorkOrderType = type,
|
||||||
|
Severity = severity,
|
||||||
|
CreatedDate = Now - age,
|
||||||
|
ScheduledDate = scheduled,
|
||||||
|
LifecycleStatus = status
|
||||||
|
};
|
||||||
|
|
||||||
|
private static TimeSpan Window(int severity) => SlaResponseWindows.Respond[severity];
|
||||||
|
|
||||||
|
private static NotificationSectionDto? SlaSection(NotificationFeedDto feed)
|
||||||
|
=> feed.Sections.SingleOrDefault(section => section.Reason == NotificationReasons.Sla);
|
||||||
|
|
||||||
|
private static IEnumerable<string> ItemIds(NotificationFeedDto feed)
|
||||||
|
=> SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty<string>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge()
|
||||||
|
{
|
||||||
|
SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes)
|
||||||
|
.Should().Equal(new Dictionary<int, double>
|
||||||
|
{
|
||||||
|
[1] = 120,
|
||||||
|
[2] = 240,
|
||||||
|
[3] = 480,
|
||||||
|
[4] = 1440,
|
||||||
|
[5] = 4320
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
[InlineData(5)]
|
||||||
|
public async Task Thresholds_AtRiskFromHalfTheWindow_BreachedFromTheWholeWindow(int severity)
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var second = TimeSpan.FromSeconds(1);
|
||||||
|
var window = Window(severity);
|
||||||
|
var level = severity.ToString();
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Sla(1, window / 2 - second, level),
|
||||||
|
Sla(2, window / 2, level),
|
||||||
|
Sla(3, window / 2 + second, level),
|
||||||
|
Sla(4, window - second, level),
|
||||||
|
Sla(5, window, level),
|
||||||
|
Sla(6, window + second, level));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
|
||||||
|
|
||||||
|
feed.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 2, 3, 4 });
|
||||||
|
ItemIds(feed).Should().BeEquivalentTo(
|
||||||
|
"sla-at-risk-2", "sla-at-risk-3", "sla-at-risk-4", "sla-breach-5", "sla-breach-6");
|
||||||
|
|
||||||
|
var section = SlaSection(feed)!;
|
||||||
|
section.Label.Should().Be("SLA at Risk");
|
||||||
|
section.Count.Should().Be(5);
|
||||||
|
section.Severity.Should().Be(NotificationSeverities.Critical);
|
||||||
|
section.Items.Where(item => item.Id.StartsWith("sla-breach-")).Should().OnlyContain(item =>
|
||||||
|
item.RowType == NotificationRowTypes.Acknowledge
|
||||||
|
&& item.Severity == NotificationSeverities.Critical
|
||||||
|
&& item.Title.EndsWith("missed its response deadline"));
|
||||||
|
section.Items.Where(item => item.Id.StartsWith("sla-at-risk-")).Should().OnlyContain(item =>
|
||||||
|
item.RowType == NotificationRowTypes.Dismissable
|
||||||
|
&& item.Severity == NotificationSeverities.High
|
||||||
|
&& item.Title.EndsWith("is at risk of missing its response deadline"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AtRisk_CarriesTheServerComputedClock()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
context.workOrders.Add(Sla(7, TimeSpan.FromMinutes(90), "1"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
|
||||||
|
|
||||||
|
var atRisk = feed.SlaAtRisk.Should().ContainSingle().Subject;
|
||||||
|
atRisk.Number.Should().Be("1007");
|
||||||
|
atRisk.Severity.Should().Be(1);
|
||||||
|
atRisk.StartedAt.Should().Be(Now.AddMinutes(-90));
|
||||||
|
atRisk.DeadlineAt.Should().Be(Now.AddMinutes(30));
|
||||||
|
atRisk.DeadlineAt.Kind.Should().Be(DateTimeKind.Utc);
|
||||||
|
atRisk.PercentElapsed.Should().Be(75);
|
||||||
|
SlaSection(feed)!.Items.Single().Title.Should().Be("WO #1007 is at risk of missing its response deadline");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnlyOpenReactiveOrEmergencyWorkOrdersWithASeverityLevelAreTracked()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var late = Window(1) * 2;
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Sla(1, late, "1", WorkOrderType.Reactive),
|
||||||
|
Sla(2, late, "2", WorkOrderType.Emergency),
|
||||||
|
Sla(3, late, "1", WorkOrderType.PM),
|
||||||
|
Sla(4, late, "1", WorkOrderType.Inspection),
|
||||||
|
Sla(5, late, "1", type: null),
|
||||||
|
Sla(6, late, severity: null),
|
||||||
|
Sla(7, late, severity: "9"),
|
||||||
|
Sla(8, late, "1", status: LifecycleStatus.Completed),
|
||||||
|
Sla(9, late, "1", status: LifecycleStatus.Canceled));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
|
||||||
|
|
||||||
|
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheClockStartsAtCreation_WhateverTheScheduledDate()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Sla(1, Window(3) + TimeSpan.FromMinutes(1), "3", scheduled: null),
|
||||||
|
// Scheduled next week, but logged three days ago: the deadline is long gone.
|
||||||
|
Sla(2, TimeSpan.FromDays(3), "4", scheduled: Now.AddDays(7)),
|
||||||
|
// Scheduled days ago, but logged ten minutes ago: nothing is due yet.
|
||||||
|
Sla(3, TimeSpan.FromMinutes(10), "4", scheduled: Now.AddDays(-5)));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
|
||||||
|
|
||||||
|
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
|
||||||
|
feed.SlaAtRisk.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatcher_OnlySeesTheirOwnWorkOrders_AndTheAccountStaysTheBoundary()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var atRisk = Window(2) * 3 / 4;
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Sla(1, atRisk, "2", assignTo: "disp-1"),
|
||||||
|
Sla(2, atRisk, "2", assignTo: "disp-2"),
|
||||||
|
Sla(3, atRisk, "2", assignTo: null),
|
||||||
|
Sla(4, atRisk, "2", assignTo: "disp-1", accountId: 2));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dispatcher = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher", "disp-1"), CancellationToken.None);
|
||||||
|
var admin = await NewFeed(context).GetFeedAsync(User(1, "Admin", "admin-1"), CancellationToken.None);
|
||||||
|
var otherAccount = await NewFeed(context).GetFeedAsync(User(2, "Admin", "admin-2"), CancellationToken.None);
|
||||||
|
|
||||||
|
dispatcher.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(1);
|
||||||
|
ItemIds(dispatcher).Should().Equal("sla-at-risk-1");
|
||||||
|
admin.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 1, 2, 3 });
|
||||||
|
otherAccount.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Breaches_AreCappedSeparately_SoAtRiskRowsAlwaysShow()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
for (var id = 1; id <= NotificationFeedService.SectionItemLimit + 10; id++)
|
||||||
|
context.workOrders.Add(Sla(id, Window(1) + TimeSpan.FromMinutes(id), "1"));
|
||||||
|
context.workOrders.Add(Sla(500, Window(5) * 3 / 4, "5"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
|
||||||
|
|
||||||
|
var section = SlaSection(feed)!;
|
||||||
|
section.Count.Should().Be(NotificationFeedService.SectionItemLimit + 11);
|
||||||
|
section.Items.Count(item => item.RowType == NotificationRowTypes.Acknowledge)
|
||||||
|
.Should().Be(NotificationFeedService.SectionItemLimit);
|
||||||
|
section.Items.Should().Contain(item => item.Id == "sla-at-risk-500");
|
||||||
|
// The newest breaches are kept.
|
||||||
|
section.Items.Should().Contain(item => item.Id == "sla-breach-1");
|
||||||
|
section.Items.Should().NotContain(item => item.Id == $"sla-breach-{NotificationFeedService.SectionItemLimit + 10}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_RecordsWhoAndWhenInTheAuditHistory_AndRemovesTheRow()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
context.Users.Add(new ApplicationUser { Id = "disp-1", UserName = "jane", FirstName = "Jane", LastName = "Doe" });
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Sla(1, Window(1) + TimeSpan.FromMinutes(5), "1"),
|
||||||
|
Sla(2, Window(1) + TimeSpan.FromMinutes(9), "1"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var user = User(1, "Dispatcher", "disp-1");
|
||||||
|
|
||||||
|
var outcome = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
|
||||||
|
var audit = await context.WorkOrderAuditLogs.SingleAsync();
|
||||||
|
audit.WorkOrderId.Should().Be(1);
|
||||||
|
audit.UserId.Should().Be("disp-1");
|
||||||
|
audit.CreatedAt.Should().Be(Now);
|
||||||
|
audit.Action.Should().Be("SLA breach acknowledged by Jane Doe");
|
||||||
|
audit.FieldName.Should().Be(SlaBreachAcknowledgementAudit.FieldName);
|
||||||
|
audit.NewValue.Should().Be("1");
|
||||||
|
audit.EventType.Should().Be("system");
|
||||||
|
|
||||||
|
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
|
||||||
|
ItemIds(feed).Should().Equal("sla-breach-2");
|
||||||
|
SlaSection(feed)!.Count.Should().Be(1);
|
||||||
|
|
||||||
|
var again = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
|
||||||
|
again.Should().Be(SlaBreachAcknowledgementOutcome.AlreadyAcknowledged);
|
||||||
|
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledgement_CoversOnlyTheSeverityThatWasBreached()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var workOrder = Sla(1, TimeSpan.FromHours(30), "2");
|
||||||
|
context.workOrders.Add(workOrder);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var user = User(1, "Dispatcher", "disp-1");
|
||||||
|
await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
|
||||||
|
|
||||||
|
workOrder.Severity = "4";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
|
||||||
|
|
||||||
|
ItemIds(feed).Should().Equal("sla-breach-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_IsRefusedOutsideTheCallersFeed()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var late = Window(1) * 2;
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Sla(1, late, "1", accountId: 2, assignTo: "disp-1"),
|
||||||
|
Sla(2, late, "1", assignTo: "disp-2"),
|
||||||
|
Sla(3, late, "1", status: LifecycleStatus.Completed),
|
||||||
|
Sla(4, late, "1", WorkOrderType.PM));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewAcknowledgement(context);
|
||||||
|
var dispatcher = User(1, "Dispatcher", "disp-1");
|
||||||
|
|
||||||
|
(await service.AcknowledgeAsync(dispatcher, 1, CancellationToken.None))
|
||||||
|
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
|
||||||
|
(await service.AcknowledgeAsync(User(1, "Admin", "admin-1"), 1, CancellationToken.None))
|
||||||
|
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
|
||||||
|
(await service.AcknowledgeAsync(dispatcher, 2, CancellationToken.None))
|
||||||
|
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
|
||||||
|
(await service.AcknowledgeAsync(dispatcher, 3, CancellationToken.None))
|
||||||
|
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
|
||||||
|
(await service.AcknowledgeAsync(dispatcher, 4, CancellationToken.None))
|
||||||
|
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
|
||||||
|
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
|
||||||
|
|
||||||
|
var otherAccountAdmin = User(2, "Admin", "admin-2");
|
||||||
|
(await service.AcknowledgeAsync(otherAccountAdmin, 1, CancellationToken.None))
|
||||||
|
.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_BeforeTheDeadlineIsRefused()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
context.workOrders.Add(Sla(1, Window(1) * 3 / 4, "1"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var outcome = await NewAcknowledgement(context)
|
||||||
|
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Should().Be(SlaBreachAcknowledgementOutcome.NotBreached);
|
||||||
|
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_WithoutANotificationRole_FailsClosed()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var act = () => NewAcknowledgement(context).AcknowledgeAsync(User(1, "Vendor"), 1, CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, "Dispatcher", 1, StatusCodes.Status204NoContent)]
|
||||||
|
[InlineData(2, "Admin", 1, StatusCodes.Status404NotFound)]
|
||||||
|
[InlineData(1, "Dispatcher", 2, StatusCodes.Status409Conflict)]
|
||||||
|
[InlineData(1, "Vendor", 1, StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task Controller_MapsAcknowledgeOutcomes(int accountId, string role, int workOrderId, int expectedStatus)
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
Sla(1, Window(1) * 2, "1"),
|
||||||
|
Sla(2, Window(1) * 3 / 4, "1"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var controller = new NotificationsController(NewFeed(context), NewAcknowledgement(context))
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext { User = User(accountId, role, "disp-1") }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.AcknowledgeSlaBreach(workOrderId, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeAssignableTo<IStatusCodeActionResult>()
|
||||||
|
.Which.StatusCode.Should().Be(expectedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_ForwardsCancellation()
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
var act = () => NewAcknowledgement(context)
|
||||||
|
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, cancellation.Token);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||||
|
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,50 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
public class NotificationsController : ControllerBase
|
public class NotificationsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly INotificationFeedService _feedService;
|
private readonly INotificationFeedService _feedService;
|
||||||
|
private readonly ISlaBreachAcknowledgementService _slaAcknowledgement;
|
||||||
|
|
||||||
public NotificationsController(INotificationFeedService feedService)
|
public NotificationsController(
|
||||||
|
INotificationFeedService feedService,
|
||||||
|
ISlaBreachAcknowledgementService slaAcknowledgement)
|
||||||
{
|
{
|
||||||
_feedService = feedService;
|
_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]
|
[HttpGet]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
|
// 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[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
|
||||||
|
|
||||||
|
private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" };
|
||||||
|
|
||||||
private const string AssignToAuditField = "AssignTo";
|
private const string AssignToAuditField = "AssignTo";
|
||||||
private const string LegacyAssignedToAuditField = "AssignedTo";
|
private const string LegacyAssignedToAuditField = "AssignedTo";
|
||||||
|
|
||||||
|
|
@ -176,6 +178,89 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return await candidates.Take(limit).ToListAsync(cancellationToken);
|
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)
|
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
|
||||||
{
|
{
|
||||||
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
|
|
|
||||||
|
|
@ -48,5 +48,22 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
|
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
|
||||||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,3 +64,38 @@ public sealed class NotificationUpliftDecisionCandidate
|
||||||
public string? DecisionNote { get; init; }
|
public string? DecisionNote { get; init; }
|
||||||
public DateTime DecidedAt { get; init; }
|
public DateTime DecidedAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per SEV level, the latest work-order creation time that has reached a response-window threshold.
|
||||||
|
/// The business service computes them; the query only compares creation times.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record NotificationSlaCutoffs(DateTime Sev1, DateTime Sev2, DateTime Sev3, DateTime Sev4, DateTime Sev5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The work-order audit entry that records an SLA breach acknowledgement: who (UserId), when (CreatedAt)
|
||||||
|
/// and the SEV level whose deadline was missed (NewValue). It is also what keeps the breach out of the feed.
|
||||||
|
/// </summary>
|
||||||
|
public static class SlaBreachAcknowledgementAudit
|
||||||
|
{
|
||||||
|
public const string FieldName = "SlaBreachAcknowledged";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An open Reactive/Emergency work order with a SEV 1–5 level and a creation time.</summary>
|
||||||
|
public sealed class NotificationSlaCandidate
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string? Number { get; init; }
|
||||||
|
public string Severity { get; init; } = "";
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
/// <summary>A breach of the work order's current severity has already been acknowledged.</summary>
|
||||||
|
public bool BreachAcknowledged { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NotificationSlaCandidates
|
||||||
|
{
|
||||||
|
/// <summary>Every work order past half its window but not past the whole window.</summary>
|
||||||
|
public IReadOnlyList<NotificationSlaCandidate> AtRisk { get; init; } = Array.Empty<NotificationSlaCandidate>();
|
||||||
|
/// <summary>Unacknowledged breaches: the newest slice, plus the unbounded total.</summary>
|
||||||
|
public IReadOnlyList<NotificationSlaCandidate> Breached { get; init; } = Array.Empty<NotificationSlaCandidate>();
|
||||||
|
public int BreachedTotal { get; init; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,26 @@ namespace SeaHaven.Services.DTOs
|
||||||
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
|
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 sealed class NotificationFeedDto
|
||||||
{
|
{
|
||||||
public DateTime GeneratedAt { get; init; }
|
public DateTime GeneratedAt { get; init; }
|
||||||
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
|
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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
SeaHaven.Services/Helpers/NotificationAudience.cs
Normal file
27
SeaHaven.Services/Helpers/NotificationAudience.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>Whose work orders the Notification Center covers for the signed-in user.</summary>
|
||||||
|
public static class NotificationAudience
|
||||||
|
{
|
||||||
|
// Dispatchers see their own work orders. Roles that already see every dispatcher's
|
||||||
|
// work on the dashboard see the whole account, including work nobody owns yet.
|
||||||
|
public static (string? DispatcherId, bool SeesUnassigned) Resolve(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (user.IsInRole("Dispatcher"))
|
||||||
|
{
|
||||||
|
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrWhiteSpace(dispatcherId))
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||||
|
return (dispatcherId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
||||||
|
return (null, true);
|
||||||
|
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
SeaHaven.Services/Helpers/SlaResponseWindows.cs
Normal file
55
SeaHaven.Services/Helpers/SlaResponseWindows.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public enum SlaState
|
||||||
|
{
|
||||||
|
OnTrack,
|
||||||
|
/// <summary>At least half of the response window has elapsed.</summary>
|
||||||
|
AtRisk,
|
||||||
|
/// <summary>The whole response window has elapsed: the response deadline was missed.</summary>
|
||||||
|
Breached
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where one work order stands against its SEV response window.</summary>
|
||||||
|
public sealed record SlaClock(int Severity, DateTime StartedAt, DateTime DeadlineAt, int PercentElapsed, SlaState State);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Respond deadlines from the client's SEV table, the same values the severity badge tooltip shows.
|
||||||
|
/// They apply to Reactive and Emergency work orders with a severity, and the clock starts when the
|
||||||
|
/// work order is created, never at its scheduled date. The table's "next day if after hours" note
|
||||||
|
/// is not modelled: the clock always counts the plain hours.
|
||||||
|
/// </summary>
|
||||||
|
public static class SlaResponseWindows
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyDictionary<int, TimeSpan> Respond = new Dictionary<int, TimeSpan>
|
||||||
|
{
|
||||||
|
[1] = TimeSpan.FromHours(2),
|
||||||
|
[2] = TimeSpan.FromHours(4),
|
||||||
|
[3] = TimeSpan.FromHours(8),
|
||||||
|
[4] = TimeSpan.FromHours(24),
|
||||||
|
[5] = TimeSpan.FromHours(72)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The latest creation time at which a work order of this level has used half its window.</summary>
|
||||||
|
public static DateTime AtRiskCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity] / 2;
|
||||||
|
|
||||||
|
/// <summary>The latest creation time at which a work order of this level has missed its deadline.</summary>
|
||||||
|
public static DateTime BreachedCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity];
|
||||||
|
|
||||||
|
/// <summary>Null when the severity is not a SEV 1–5 level.</summary>
|
||||||
|
public static SlaClock? Evaluate(string? severity, DateTime createdAt, DateTime now)
|
||||||
|
{
|
||||||
|
var level = WorkOrderSeverityRules.ParseLevel(severity);
|
||||||
|
if (level is not int sev)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var window = Respond[sev];
|
||||||
|
var startedAt = DateTime.SpecifyKind(createdAt, DateTimeKind.Utc);
|
||||||
|
var elapsed = now - startedAt;
|
||||||
|
var state = elapsed >= window
|
||||||
|
? SlaState.Breached
|
||||||
|
: elapsed >= window / 2 ? SlaState.AtRisk : SlaState.OnTrack;
|
||||||
|
var percent = (int)Math.Floor(Math.Max(elapsed.Ticks, 0) * 100d / window.Ticks);
|
||||||
|
return new SlaClock(sev, startedAt, startedAt + window, percent, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,11 +41,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
|
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
|
var (dispatcherId, seesUnassigned) = NotificationAudience.Resolve(user);
|
||||||
var scope = new NotificationFeedScope(accountId, dispatcherId);
|
var scope = new NotificationFeedScope(accountId, dispatcherId);
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
|
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
|
||||||
|
var (slaSection, slaAtRisk) = await SlaAsync(scope, now, cancellationToken);
|
||||||
|
sections.Add(slaSection);
|
||||||
if (seesUnassigned)
|
if (seesUnassigned)
|
||||||
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
|
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
|
||||||
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
|
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
|
||||||
|
|
@ -59,28 +61,80 @@ namespace SeaHaven.Services.Implementation
|
||||||
return new NotificationFeedDto
|
return new NotificationFeedDto
|
||||||
{
|
{
|
||||||
GeneratedAt = now,
|
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
|
// Reactive/Emergency work orders against their SEV response window. An at-risk work order is a
|
||||||
// work on the dashboard see the whole account, including work nobody owns yet.
|
// normal dismissable row (and feeds the banner); a missed deadline is an acknowledge row that stays
|
||||||
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
|
// 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 candidates = await _data.GetSlaCandidatesAsync(
|
||||||
{
|
scope,
|
||||||
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
SlaCutoffs(level => SlaResponseWindows.AtRiskCreatedAtOrBefore(level, now)),
|
||||||
if (string.IsNullOrWhiteSpace(dispatcherId))
|
SlaCutoffs(level => SlaResponseWindows.BreachedCreatedAtOrBefore(level, now)),
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
SectionItemLimit,
|
||||||
return (dispatcherId, false);
|
cancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
var atRisk = candidates.AtRisk
|
||||||
return (null, true);
|
.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
|
// 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
|
// 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
|
// account scope still applies; the dispatcher scope does not, because a mention or an uplift
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class SlaBreachAcknowledgementService : ISlaBreachAcknowledgementService
|
||||||
|
{
|
||||||
|
private readonly INotificationFeedDataService _feedData;
|
||||||
|
private readonly IWorkOrderAuditDataService _auditData;
|
||||||
|
private readonly IUserDataService _userData;
|
||||||
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public SlaBreachAcknowledgementService(
|
||||||
|
INotificationFeedDataService feedData,
|
||||||
|
IWorkOrderAuditDataService auditData,
|
||||||
|
IUserDataService userData,
|
||||||
|
IWorkOrderAccountResolver accountResolver,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_feedData = feedData;
|
||||||
|
_auditData = auditData;
|
||||||
|
_userData = userData;
|
||||||
|
_accountResolver = accountResolver;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SlaBreachAcknowledgementOutcome> AcknowledgeAsync(
|
||||||
|
ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// The same audience as the feed: a row the caller cannot see cannot be acknowledged.
|
||||||
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||||
|
var (dispatcherId, _) = NotificationAudience.Resolve(user);
|
||||||
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "Acknowledging requires a signed-in user.");
|
||||||
|
|
||||||
|
var candidate = await _feedData.GetSlaCandidateAsync(
|
||||||
|
new NotificationFeedScope(accountId, dispatcherId), workOrderId, cancellationToken);
|
||||||
|
if (candidate == null)
|
||||||
|
return SlaBreachAcknowledgementOutcome.NotFound;
|
||||||
|
|
||||||
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
var clock = SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now);
|
||||||
|
if (clock is not { State: SlaState.Breached })
|
||||||
|
return SlaBreachAcknowledgementOutcome.NotBreached;
|
||||||
|
if (candidate.BreachAcknowledged)
|
||||||
|
return SlaBreachAcknowledgementOutcome.AlreadyAcknowledged;
|
||||||
|
|
||||||
|
var names = await _userData.GetDisplayNamesByIdsAsync(new[] { userId });
|
||||||
|
var name = names.TryGetValue(userId, out var displayName) && !string.IsNullOrWhiteSpace(displayName)
|
||||||
|
? displayName
|
||||||
|
: user.FindFirstValue(ClaimTypes.Name) ?? userId;
|
||||||
|
|
||||||
|
_auditData.EnqueueAuditLog(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
UserId = userId,
|
||||||
|
FieldName = SlaBreachAcknowledgementAudit.FieldName,
|
||||||
|
OldValue = clock.DeadlineAt.ToString("o"),
|
||||||
|
NewValue = candidate.Severity,
|
||||||
|
Action = $"SLA breach acknowledged by {name}",
|
||||||
|
EventType = "system",
|
||||||
|
ActorType = "internal",
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _auditData.PersistAsync(cancellationToken);
|
||||||
|
return SlaBreachAcknowledgementOutcome.Acknowledged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue