mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
feat(notifications): SEV response-window alerts and breach acknowledgement
Reactive/Emergency work orders with a SEV 1-5 level are timed from their
creation against the SEV Respond deadline (2/4/8/24/72 hours, one backend
table). From 50% they are at risk: a dismissable High row in the "SLA at
Risk" section and an entry in the feed's slaAtRisk set with the server
clock (start, deadline, percent) for the banner and toast. From 100% they
are a Critical acknowledge row that only acknowledging removes.
POST /api/notifications/sla/{id}/acknowledge records who and when as a
work-order audit entry ("SLA breach acknowledged by <name>"), scoped to the
caller's feed audience: 404 outside it, 409 before the deadline, 204 when
recorded or already recorded. A later severity change is a new breach.
This commit is contained in:
parent
06eae2fb02
commit
14c8e46dd0
12 changed files with 853 additions and 18 deletions
|
|
@ -7,11 +7,13 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
|
@ -546,7 +548,7 @@ public class NotificationFeedServiceTests
|
|||
public async Task Controller_MapsForbiddenScopeTo403()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var controller = new NotificationsController(NewService(context))
|
||||
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
|
|
@ -565,7 +567,7 @@ public class NotificationFeedServiceTests
|
|||
using var context = NewContext();
|
||||
context.workOrders.Add(Open(1));
|
||||
await context.SaveChangesAsync();
|
||||
var controller = new NotificationsController(NewService(context))
|
||||
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
|
|
|
|||
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
|
||||
{
|
||||
private readonly INotificationFeedService _feedService;
|
||||
private readonly ISlaBreachAcknowledgementService _slaAcknowledgement;
|
||||
|
||||
public NotificationsController(INotificationFeedService feedService)
|
||||
public NotificationsController(
|
||||
INotificationFeedService feedService,
|
||||
ISlaBreachAcknowledgementService slaAcknowledgement)
|
||||
{
|
||||
_feedService = feedService;
|
||||
_slaAcknowledgement = slaAcknowledgement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges the missed response deadline of one work order in the caller's Notification Center.
|
||||
/// 204 when recorded or already recorded; 404 when the caller's feed does not cover the work order;
|
||||
/// 409 when its deadline has not been missed.
|
||||
/// </summary>
|
||||
[HttpPost("sla/{workOrderId:int}/acknowledge")]
|
||||
public async Task<IActionResult> AcknowledgeSlaBreach(int workOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var outcome = await _slaAcknowledgement.AcknowledgeAsync(User, workOrderId, cancellationToken);
|
||||
return outcome switch
|
||||
{
|
||||
SlaBreachAcknowledgementOutcome.NotFound => NotFound(new
|
||||
{
|
||||
code = "NotFound",
|
||||
message = "Work order not found."
|
||||
}),
|
||||
SlaBreachAcknowledgementOutcome.NotBreached => Conflict(new
|
||||
{
|
||||
code = "NotBreached",
|
||||
message = "This work order has not missed its response deadline."
|
||||
}),
|
||||
_ => NoContent()
|
||||
};
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
|
||||
private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
|
||||
|
||||
private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" };
|
||||
|
||||
private const string AssignToAuditField = "AssignTo";
|
||||
private const string LegacyAssignedToAuditField = "AssignedTo";
|
||||
|
||||
|
|
@ -176,6 +178,89 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return await candidates.Take(limit).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
|
||||
NotificationFeedScope scope,
|
||||
NotificationSlaCutoffs atRisk,
|
||||
NotificationSlaCutoffs breached,
|
||||
int breachedLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var eligible = SlaEligibleWorkOrders(scope);
|
||||
|
||||
// Bounded by the longest window: nothing created more than 72 hours ago is still at risk.
|
||||
var atRiskItems = await ProjectSla(CreatedAfter(CreatedAtOrBefore(eligible, atRisk), breached))
|
||||
.OrderBy(candidate => candidate.CreatedAt)
|
||||
.ThenBy(candidate => candidate.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Breaches stay until acknowledged, so legacy open work orders can make this set large.
|
||||
var unacknowledged = CreatedAtOrBefore(eligible, breached)
|
||||
.Where(w => !_context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
|
||||
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
|
||||
&& log.NewValue == w.Severity));
|
||||
var breachedTotal = await unacknowledged.CountAsync(cancellationToken);
|
||||
var breachedItems = breachedTotal == 0
|
||||
? new List<NotificationSlaCandidate>()
|
||||
: await ProjectSla(unacknowledged)
|
||||
.OrderByDescending(candidate => candidate.CreatedAt)
|
||||
.ThenByDescending(candidate => candidate.Id)
|
||||
.Take(breachedLimit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new NotificationSlaCandidates
|
||||
{
|
||||
AtRisk = atRiskItems,
|
||||
Breached = breachedItems,
|
||||
BreachedTotal = breachedTotal
|
||||
};
|
||||
}
|
||||
|
||||
public Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
|
||||
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken)
|
||||
=> ProjectSla(SlaEligibleWorkOrders(scope).Where(w => w.Id == workOrderId))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
// Same rule as the board's severity facet: Reactive or Emergency with a stored SEV level "1"–"5".
|
||||
private IQueryable<WorkOrder> SlaEligibleWorkOrders(NotificationFeedScope scope)
|
||||
=> OpenWorkOrders(scope).Where(w =>
|
||||
(w.WorkOrderType == WorkOrderType.Reactive || w.WorkOrderType == WorkOrderType.Emergency)
|
||||
&& SlaSeverities.Contains(w.Severity ?? "")
|
||||
&& w.CreatedDate != null);
|
||||
|
||||
private static IQueryable<WorkOrder> CreatedAtOrBefore(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
|
||||
{
|
||||
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
|
||||
return workOrders.Where(w =>
|
||||
(w.Severity == "1" && w.CreatedDate <= sev1)
|
||||
|| (w.Severity == "2" && w.CreatedDate <= sev2)
|
||||
|| (w.Severity == "3" && w.CreatedDate <= sev3)
|
||||
|| (w.Severity == "4" && w.CreatedDate <= sev4)
|
||||
|| (w.Severity == "5" && w.CreatedDate <= sev5));
|
||||
}
|
||||
|
||||
private static IQueryable<WorkOrder> CreatedAfter(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
|
||||
{
|
||||
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
|
||||
return workOrders.Where(w =>
|
||||
(w.Severity == "1" && w.CreatedDate > sev1)
|
||||
|| (w.Severity == "2" && w.CreatedDate > sev2)
|
||||
|| (w.Severity == "3" && w.CreatedDate > sev3)
|
||||
|| (w.Severity == "4" && w.CreatedDate > sev4)
|
||||
|| (w.Severity == "5" && w.CreatedDate > sev5));
|
||||
}
|
||||
|
||||
private IQueryable<NotificationSlaCandidate> ProjectSla(IQueryable<WorkOrder> workOrders)
|
||||
=> workOrders.Select(w => new NotificationSlaCandidate
|
||||
{
|
||||
Id = w.Id,
|
||||
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||||
Severity = w.Severity!,
|
||||
CreatedAt = w.CreatedDate!.Value,
|
||||
BreachAcknowledged = _context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
|
||||
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
|
||||
&& log.NewValue == w.Severity)
|
||||
});
|
||||
|
||||
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
|
||||
{
|
||||
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
|
|
|
|||
|
|
@ -48,5 +48,22 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
/// </summary>
|
||||
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
|
||||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Open Reactive/Emergency work orders with a SEV 1–5 level, split by creation time: at risk when
|
||||
/// created at or before <paramref name="atRisk"/> but after <paramref name="breached"/>, breached when
|
||||
/// created at or before <paramref name="breached"/> and not yet acknowledged for their current level.
|
||||
/// Breaches are the <paramref name="breachedLimit"/> newest; the at-risk set is bounded by the longest window.
|
||||
/// </summary>
|
||||
Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
|
||||
NotificationFeedScope scope,
|
||||
NotificationSlaCutoffs atRisk,
|
||||
NotificationSlaCutoffs breached,
|
||||
int breachedLimit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The work order as an SLA candidate inside <paramref name="scope"/>, or null when it is not one.</summary>
|
||||
Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
|
||||
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,3 +64,38 @@ public sealed class NotificationUpliftDecisionCandidate
|
|||
public string? DecisionNote { get; init; }
|
||||
public DateTime DecidedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per SEV level, the latest work-order creation time that has reached a response-window threshold.
|
||||
/// The business service computes them; the query only compares creation times.
|
||||
/// </summary>
|
||||
public sealed record NotificationSlaCutoffs(DateTime Sev1, DateTime Sev2, DateTime Sev3, DateTime Sev4, DateTime Sev5);
|
||||
|
||||
/// <summary>
|
||||
/// The work-order audit entry that records an SLA breach acknowledgement: who (UserId), when (CreatedAt)
|
||||
/// and the SEV level whose deadline was missed (NewValue). It is also what keeps the breach out of the feed.
|
||||
/// </summary>
|
||||
public static class SlaBreachAcknowledgementAudit
|
||||
{
|
||||
public const string FieldName = "SlaBreachAcknowledged";
|
||||
}
|
||||
|
||||
/// <summary>An open Reactive/Emergency work order with a SEV 1–5 level and a creation time.</summary>
|
||||
public sealed class NotificationSlaCandidate
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string? Number { get; init; }
|
||||
public string Severity { get; init; } = "";
|
||||
public DateTime CreatedAt { get; init; }
|
||||
/// <summary>A breach of the work order's current severity has already been acknowledged.</summary>
|
||||
public bool BreachAcknowledged { get; init; }
|
||||
}
|
||||
|
||||
public sealed class NotificationSlaCandidates
|
||||
{
|
||||
/// <summary>Every work order past half its window but not past the whole window.</summary>
|
||||
public IReadOnlyList<NotificationSlaCandidate> AtRisk { get; init; } = Array.Empty<NotificationSlaCandidate>();
|
||||
/// <summary>Unacknowledged breaches: the newest slice, plus the unbounded total.</summary>
|
||||
public IReadOnlyList<NotificationSlaCandidate> Breached { get; init; } = Array.Empty<NotificationSlaCandidate>();
|
||||
public int BreachedTotal { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,9 +126,26 @@ namespace SeaHaven.Services.DTOs
|
|||
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A work order past half of its SEV response window and not yet past the whole window, with the
|
||||
/// server-computed clock. Drives the top-of-app banner and the one-time 50% toast.
|
||||
/// </summary>
|
||||
public sealed class NotificationSlaWorkOrderDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string? Number { get; init; }
|
||||
public int Severity { get; init; }
|
||||
/// <summary>The work order's creation time; the response clock never starts at the scheduled date.</summary>
|
||||
public DateTime StartedAt { get; init; }
|
||||
public DateTime DeadlineAt { get; init; }
|
||||
public int PercentElapsed { get; init; }
|
||||
}
|
||||
|
||||
public sealed class NotificationFeedDto
|
||||
{
|
||||
public DateTime GeneratedAt { get; init; }
|
||||
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
|
||||
/// <summary>Every at-risk work order in the feed's scope, oldest first; not capped like section items.</summary>
|
||||
public IReadOnlyList<NotificationSlaWorkOrderDto> SlaAtRisk { get; init; } = Array.Empty<NotificationSlaWorkOrderDto>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
|
||||
var (dispatcherId, seesUnassigned) = NotificationAudience.Resolve(user);
|
||||
var scope = new NotificationFeedScope(accountId, dispatcherId);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
|
||||
var (slaSection, slaAtRisk) = await SlaAsync(scope, now, cancellationToken);
|
||||
sections.Add(slaSection);
|
||||
if (seesUnassigned)
|
||||
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
|
||||
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
|
||||
|
|
@ -59,28 +61,80 @@ namespace SeaHaven.Services.Implementation
|
|||
return new NotificationFeedDto
|
||||
{
|
||||
GeneratedAt = now,
|
||||
Sections = NotificationFeedOrdering.Order(sections)
|
||||
Sections = NotificationFeedOrdering.Order(sections),
|
||||
SlaAtRisk = slaAtRisk
|
||||
};
|
||||
}
|
||||
|
||||
// Dispatchers see their own work orders. Roles that already see every dispatcher's
|
||||
// work on the dashboard see the whole account, including work nobody owns yet.
|
||||
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
|
||||
// Reactive/Emergency work orders against their SEV response window. An at-risk work order is a
|
||||
// normal dismissable row (and feeds the banner); a missed deadline is an acknowledge row that stays
|
||||
// until someone acknowledges it. Breaches and at-risk rows are capped separately so a backlog of
|
||||
// old breaches can never hide a work order that can still be saved.
|
||||
private async Task<((string, int, IReadOnlyList<NotificationItemDto>) Section, IReadOnlyList<NotificationSlaWorkOrderDto> AtRisk)> SlaAsync(
|
||||
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (user.IsInRole("Dispatcher"))
|
||||
{
|
||||
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
return (dispatcherId, false);
|
||||
}
|
||||
var candidates = await _data.GetSlaCandidatesAsync(
|
||||
scope,
|
||||
SlaCutoffs(level => SlaResponseWindows.AtRiskCreatedAtOrBefore(level, now)),
|
||||
SlaCutoffs(level => SlaResponseWindows.BreachedCreatedAtOrBefore(level, now)),
|
||||
SectionItemLimit,
|
||||
cancellationToken);
|
||||
|
||||
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
||||
return (null, true);
|
||||
var atRisk = candidates.AtRisk
|
||||
.Select(candidate => (Candidate: candidate, Clock: SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now)))
|
||||
.Where(entry => entry.Clock is { State: SlaState.AtRisk })
|
||||
.Select(entry => new NotificationSlaWorkOrderDto
|
||||
{
|
||||
Id = entry.Candidate.Id,
|
||||
Number = entry.Candidate.Number,
|
||||
Severity = entry.Clock!.Severity,
|
||||
StartedAt = entry.Clock.StartedAt,
|
||||
DeadlineAt = entry.Clock.DeadlineAt,
|
||||
PercentElapsed = entry.Clock.PercentElapsed
|
||||
})
|
||||
.ToList();
|
||||
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
|
||||
var items = candidates.Breached
|
||||
.Select(candidate => SlaItem(candidate, SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now)))
|
||||
.OfType<NotificationItemDto>()
|
||||
.Concat(atRisk
|
||||
.OrderByDescending(workOrder => workOrder.PercentElapsed)
|
||||
.Take(SectionItemLimit)
|
||||
.Select(workOrder => new NotificationItemDto
|
||||
{
|
||||
Id = $"sla-at-risk-{workOrder.Id}",
|
||||
Reason = NotificationReasons.Sla,
|
||||
Severity = NotificationSeverities.High,
|
||||
Title = $"{WorkOrderLabel(workOrder.Id, workOrder.Number)} is at risk of missing its response deadline",
|
||||
Count = 1,
|
||||
TriggeredAt = workOrder.StartedAt + SlaResponseWindows.Respond[workOrder.Severity] / 2,
|
||||
Target = WorkOrderTarget(workOrder.Id, NotificationWorkOrderTabs.Info),
|
||||
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = workOrder.Id, Number = workOrder.Number } }
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
return ((NotificationReasons.Sla, candidates.BreachedTotal + atRisk.Count, items), atRisk);
|
||||
}
|
||||
|
||||
private static NotificationItemDto? SlaItem(NotificationSlaCandidate candidate, SlaClock? clock)
|
||||
=> clock is not { State: SlaState.Breached }
|
||||
? null
|
||||
: new NotificationItemDto
|
||||
{
|
||||
Id = $"sla-breach-{candidate.Id}",
|
||||
Reason = NotificationReasons.Sla,
|
||||
Severity = NotificationSeverities.Critical,
|
||||
RowType = NotificationRowTypes.Acknowledge,
|
||||
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} missed its response deadline",
|
||||
Count = 1,
|
||||
TriggeredAt = clock.DeadlineAt,
|
||||
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
|
||||
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
||||
};
|
||||
|
||||
private static NotificationSlaCutoffs SlaCutoffs(Func<int, DateTime> cutoff)
|
||||
=> new(cutoff(1), cutoff(2), cutoff(3), cutoff(4), cutoff(5));
|
||||
|
||||
// Items addressed to the signed-in user whatever their role: assignments to them, comments on
|
||||
// work they take part in, mentions of them, and decisions on uplifts they requested. The
|
||||
// account scope still applies; the dispatcher scope does not, because a mention or an uplift
|
||||
|
|
|
|||
|
|
@ -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