shoc-backend/SeaHaven.Services/Implementation/NotificationFeedService.cs

291 lines
16 KiB
C#
Raw Normal View History

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