mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +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.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