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 { /// Most items returned per per-work-order section; section counts stay exact. public const int SectionItemLimit = 50; /// How long an assignment, comment, mention or uplift decision stays in the feed. public static readonly TimeSpan RecentActivityWindow = TimeSpan.FromDays(7); /// Most source rows read per personal event type before grouping. 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 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 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)>> 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) BoundedSection( string reason, IReadOnlyList 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)> UnassignedAsync( int? accountId, DateTime now, CancellationToken cancellationToken) { var summary = await _data.GetUnassignedSummaryAsync(accountId, cancellationToken); if (summary.Count == 0) return (NotificationReasons.Unassigned, 0, Array.Empty()); 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)> 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)> 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)> 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); } }