mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 14:13:12 +00:00
236 lines
12 KiB
C#
236 lines
12 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) = ResolveAudience(user);
|
|
var scope = new NotificationFeedScope(accountId, dispatcherId);
|
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
|
|
|
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
|
|
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)
|
|
};
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
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.");
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|