mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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.
290 lines
16 KiB
C#
290 lines
16 KiB
C#
using System.Security.Claims;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.DataServices.Models;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class NotificationFeedService : INotificationFeedService
|
|
{
|
|
/// <summary>Most items returned per per-work-order section; section counts stay exact.</summary>
|
|
public const int SectionItemLimit = 50;
|
|
|
|
/// <summary>How long an assignment, comment, mention or uplift decision stays in the feed.</summary>
|
|
public static readonly TimeSpan RecentActivityWindow = TimeSpan.FromDays(7);
|
|
|
|
/// <summary>Most source rows read per personal event type before grouping.</summary>
|
|
public const int PersonalCandidateLimit = 500;
|
|
|
|
private static readonly TimeSpan NoVendorLeadTime = TimeSpan.FromHours(48);
|
|
|
|
private readonly INotificationFeedDataService _data;
|
|
private readonly IVendorOperationsDataService _vendorData;
|
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public NotificationFeedService(
|
|
INotificationFeedDataService data,
|
|
IVendorOperationsDataService vendorData,
|
|
IWorkOrderAccountResolver accountResolver,
|
|
TimeProvider timeProvider)
|
|
{
|
|
_data = data;
|
|
_vendorData = vendorData;
|
|
_accountResolver = accountResolver;
|
|
_timeProvider = timeProvider;
|
|
}
|
|
|
|
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
|
|
{
|
|
var accountId = _accountResolver.ResolveAccountFilter(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));
|
|
sections.Add(await AvetaMissingAsync(scope, now, cancellationToken));
|
|
sections.Add(await VendorConflictsAsync(scope, cancellationToken));
|
|
|
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (!string.IsNullOrWhiteSpace(userId))
|
|
sections.AddRange(await PersonalSectionsAsync(new NotificationPersonalScope(accountId, userId), now, cancellationToken));
|
|
|
|
return new NotificationFeedDto
|
|
{
|
|
GeneratedAt = now,
|
|
Sections = NotificationFeedOrdering.Order(sections),
|
|
SlaAtRisk = slaAtRisk
|
|
};
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
var candidates = await _data.GetSlaCandidatesAsync(
|
|
scope,
|
|
SlaCutoffs(level => SlaResponseWindows.AtRiskCreatedAtOrBefore(level, now)),
|
|
SlaCutoffs(level => SlaResponseWindows.BreachedCreatedAtOrBefore(level, now)),
|
|
SectionItemLimit,
|
|
cancellationToken);
|
|
|
|
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();
|
|
|
|
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
|
|
// request can concern a work order owned by another dispatcher.
|
|
private async Task<IReadOnlyList<(string, int, IReadOnlyList<NotificationItemDto>)>> PersonalSectionsAsync(
|
|
NotificationPersonalScope scope, DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
var since = now.Subtract(RecentActivityWindow);
|
|
var comments = await _data.GetUnansweredCommentsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
|
|
var assignments = await _data.GetRecentAssignmentsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
|
|
var decisions = await _data.GetUpliftDecisionsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
|
|
|
|
var mentions = NotificationActivityItems.Mentions(comments, scope.UserId);
|
|
var activity = NotificationActivityItems.Comments(comments, scope.UserId)
|
|
.Concat(NotificationActivityItems.Assignments(assignments))
|
|
.Concat(NotificationActivityItems.UpliftDecisions(decisions))
|
|
.ToList();
|
|
|
|
return new[]
|
|
{
|
|
BoundedSection(NotificationReasons.Mentions, mentions),
|
|
BoundedSection(NotificationReasons.Activity, activity)
|
|
};
|
|
}
|
|
|
|
// Counts every work order the section covers; returns the most severe, most recent items.
|
|
private static (string, int, IReadOnlyList<NotificationItemDto>) BoundedSection(
|
|
string reason, IReadOnlyList<NotificationItemDto> items)
|
|
{
|
|
var workOrderCount = items.SelectMany(item => item.WorkOrders).Select(w => w.Id).Distinct().Count();
|
|
var bounded = items
|
|
.OrderByDescending(item => NotificationSeverities.Rank(item.Severity))
|
|
.ThenByDescending(item => item.TriggeredAt)
|
|
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
|
.Take(SectionItemLimit)
|
|
.ToList();
|
|
return (reason, workOrderCount, bounded);
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> UnassignedAsync(
|
|
int? accountId, DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
var summary = await _data.GetUnassignedSummaryAsync(accountId, cancellationToken);
|
|
if (summary.Count == 0)
|
|
return (NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>());
|
|
|
|
var item = new NotificationItemDto
|
|
{
|
|
Id = NotificationReasons.Unassigned,
|
|
Reason = NotificationReasons.Unassigned,
|
|
Severity = NotificationSeverities.High,
|
|
Title = summary.Count == 1
|
|
? "1 work order is unassigned"
|
|
: $"{summary.Count} work orders are unassigned",
|
|
Count = summary.Count,
|
|
TriggeredAt = summary.LatestCreatedAt ?? now,
|
|
Target = new NotificationTargetDto
|
|
{
|
|
Kind = NotificationTargetKinds.Queue,
|
|
Queue = NotificationQueues.Unassigned
|
|
}
|
|
};
|
|
return (NotificationReasons.Unassigned, summary.Count, new[] { item });
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> NoVendorAsync(
|
|
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
var page = await _data.GetNoVendorAsync(scope, now.Add(NoVendorLeadTime), SectionItemLimit, cancellationToken);
|
|
var items = page.Items.Select(candidate => new NotificationItemDto
|
|
{
|
|
Id = $"no-vendor-{candidate.Id}",
|
|
Reason = NotificationReasons.NoVendor,
|
|
Severity = NotificationSeverities.Medium,
|
|
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} starts within 48h with no vendor assigned",
|
|
Count = 1,
|
|
TriggeredAt = candidate.ScheduledDate.Subtract(NoVendorLeadTime),
|
|
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
|
|
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
|
}).ToList();
|
|
return (NotificationReasons.NoVendor, page.Total, items);
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> AvetaMissingAsync(
|
|
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
var today = NotificationBusinessTime.Today(now);
|
|
var page = await _data.GetAvetaMissingAsync(scope, today, SectionItemLimit, cancellationToken);
|
|
var items = page.Items.Select(candidate => new NotificationItemDto
|
|
{
|
|
Id = $"aveta-missing-{candidate.Id}",
|
|
Reason = NotificationReasons.AvetaMissing,
|
|
Severity = NotificationSeverities.Medium,
|
|
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} is missing the Aveta document",
|
|
Count = 1,
|
|
// The one-day window opens the day before the scheduled date.
|
|
TriggeredAt = candidate.ScheduledDate.Date.AddDays(-1),
|
|
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Extras),
|
|
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
|
}).ToList();
|
|
return (NotificationReasons.AvetaMissing, page.Total, items);
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> VendorConflictsAsync(
|
|
NotificationFeedScope scope, CancellationToken cancellationToken)
|
|
{
|
|
var dispatches = await _vendorData.GetActiveScheduledDispatchesAsync(scope.AccountId, cancellationToken);
|
|
var pairs = VendorConflictPairs.Build(dispatches)
|
|
.Where(pair => scope.DispatcherId == null
|
|
|| pair.Left.AssignTo == scope.DispatcherId
|
|
|| pair.Right.AssignTo == scope.DispatcherId)
|
|
.ToList();
|
|
var workOrderCount = pairs
|
|
.SelectMany(pair => new[] { pair.Left.WorkOrderId, pair.Right.WorkOrderId })
|
|
.Distinct()
|
|
.Count();
|
|
|
|
// Cap by recency like the other sections: keep the newest overlaps so a section that
|
|
// exceeds the limit never drops a more recent conflict in favour of an older one.
|
|
var items = pairs
|
|
.OrderByDescending(pair => pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start)
|
|
.ThenByDescending(pair => pair.Left.Id)
|
|
.ThenByDescending(pair => pair.Right.Id)
|
|
.Take(SectionItemLimit)
|
|
.Select(pair => new NotificationItemDto
|
|
{
|
|
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
|
Reason = NotificationReasons.VendorConflict,
|
|
Severity = NotificationSeverities.Medium,
|
|
Title = $"{(string.IsNullOrWhiteSpace(pair.Left.VendorName) ? "Vendor" : pair.Left.VendorName)} has overlapping appointments",
|
|
Count = 2,
|
|
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
|
Target = WorkOrderTarget(pair.Left.WorkOrderId, NotificationWorkOrderTabs.Info),
|
|
WorkOrders = new[]
|
|
{
|
|
new NotificationWorkOrderRefDto { Id = pair.Left.WorkOrderId, Number = pair.Left.WorkOrderNumber },
|
|
new NotificationWorkOrderRefDto { Id = pair.Right.WorkOrderId, Number = pair.Right.WorkOrderNumber }
|
|
}
|
|
}).ToList();
|
|
return (NotificationReasons.VendorConflict, workOrderCount, items);
|
|
}
|
|
|
|
private static NotificationTargetDto WorkOrderTarget(int workOrderId, string tab) => new()
|
|
{
|
|
Kind = NotificationTargetKinds.WorkOrder,
|
|
WorkOrderId = workOrderId,
|
|
Tab = tab
|
|
};
|
|
|
|
private static string WorkOrderLabel(int id, string? number)
|
|
=> NotificationActivityItems.WorkOrderLabel(id, number);
|
|
}
|
|
}
|