using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { public class NotificationFeedDataService : INotificationFeedDataService { private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" }; // 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"; private readonly ApplicationDbContext _context; public NotificationFeedDataService(ApplicationDbContext context) { _context = context; } public async Task GetUnassignedSummaryAsync( int? accountId, CancellationToken cancellationToken) { var summary = await OpenWorkOrders(new NotificationFeedScope(accountId, null)) .Where(w => w.AssignTo == null || w.AssignTo == "") .GroupBy(_ => 1) .Select(group => new UnassignedWorkOrdersSummary { Count = group.Count(), LatestCreatedAt = group.Max(w => w.CreatedDate) }) .FirstOrDefaultAsync(cancellationToken); return summary ?? new UnassignedWorkOrdersSummary(); } public Task GetAvetaMissingAsync( NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken) { // Same rule as the dashboard's Aveta Pending tile so the two counts agree. var windowStart = today.ToDateTime(TimeOnly.MinValue); var windowEnd = today.AddDays(2).ToDateTime(TimeOnly.MinValue); var candidates = OpenWorkOrders(scope) .Where(w => w.AvetaRequired && w.ScheduledDate >= windowStart && w.ScheduledDate < windowEnd && !w.workOrderAttachments!.Any(attachment => (attachment.IsDeleted == null || attachment.IsDeleted == false) && attachment.Category == WorkOrderMediaCategory.Aveta)); return PageAsync(candidates, limit, cancellationToken); } public Task GetNoVendorAsync( NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken) { // "Has a vendor" matches the vendor reminders: any live non-terminal dispatch that // points at the work order directly or links it through DispatchWorkOrders. Soft-deleted // dispatches are ignored so a removed dispatch does not keep the row out of No Vendor. var candidates = OpenWorkOrders(scope) .Where(w => w.ScheduledDate.HasValue && w.ScheduledDate.Value <= cutoff && !_context.Dispatches.Any(d => d.WorkOrderId == w.Id && (d.IsDeleted == null || d.IsDeleted == false) && !TerminalDispatchStatuses.Contains(d.Status ?? "")) && !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id && link.Dispatch != null && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))); return PageAsync(candidates, limit, cancellationToken); } public async Task> GetRecentAssignmentsAsync( NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken) { // Board create and board edits log "AssignTo" with user ids; the legacy assignment endpoint // logs "AssignedTo" with display names. Only the time and the actor are read, so both count. var assignmentLogs = _context.WorkOrderAuditLogs.AsNoTracking() .Where(log => log.FieldName == AssignToAuditField || log.FieldName == LegacyAssignedToAuditField); // The latest assignment entry of each work order the user holds now. var candidates = from log in assignmentLogs join w in OpenWorkOrders(new NotificationFeedScope(scope.AccountId, scope.UserId)) on log.WorkOrderId equals w.Id where log.CreatedAt >= since && (log.UserId == null || log.UserId != scope.UserId) && !assignmentLogs.Any(later => later.WorkOrderId == log.WorkOrderId && (later.CreatedAt > log.CreatedAt || (later.CreatedAt == log.CreatedAt && later.Id > log.Id))) orderby log.CreatedAt descending, w.Id descending select new NotificationAssignmentCandidate { WorkOrderId = w.Id, Number = w.InternalWONumber ?? w.WorkerOrderNumber, AssignedAt = log.CreatedAt, AssignedBy = log.UserId }; return await candidates.Take(limit).ToListAsync(cancellationToken); } public async Task> GetUnansweredCommentsAsync( NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken) { var userId = scope.UserId; // SH-217 stores a mention as the token "@[userId:Display Name]" inside the comment text. var mentionToken = "@[" + userId + ":"; var ownComments = _context.Comments.AsNoTracking() .Where(own => own.UserId == userId && own.IsDeleted != true); var candidates = from c in _context.Comments.AsNoTracking() join w in ScopedWorkOrders(scope.AccountId) on c.WorkerOrderId equals w.Id where c.IsDeleted != true && c.CreatedDate != null && c.CreatedDate >= since && (c.UserId == null || c.UserId != userId) && (w.AssignTo == userId || (c.Commenttext != null && c.Commenttext.Contains(mentionToken)) || ownComments.Any(own => own.WorkerOrderId == w.Id)) && !ownComments.Any(own => own.WorkerOrderId == w.Id && own.CreatedDate >= c.CreatedDate) orderby c.CreatedDate descending, c.Id descending select new NotificationCommentCandidate { CommentId = c.Id, WorkOrderId = w.Id, WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber, AuthorFirstName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : null, AuthorLastName = c.ApplicationUser != null ? c.ApplicationUser.LastName : null, Commenter = c.Commenter, Text = c.Commenttext, CreatedAt = c.CreatedDate!.Value }; return await candidates.Take(limit).ToListAsync(cancellationToken); } public async Task> GetUpliftDecisionsAsync( NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken) { var userId = scope.UserId; // Same work-order resolution as the uplift reads: the dispatch's own work order, // otherwise the first work order linked through DispatchWorkOrders. var candidates = from u in _context.DispatchUpliftRequests.AsNoTracking() join d in _context.Dispatches.AsNoTracking() on u.DispatchId equals d.Id let workOrderId = d.WorkOrderId ?? d.DispatchWorkOrders!.Select(link => (int?)link.WorkOrderId).FirstOrDefault() join w in ScopedWorkOrders(scope.AccountId) on workOrderId equals (int?)w.Id where u.IsDeleted != true && d.IsDeleted != true && u.createdby == userId && u.DecidedAt != null && u.DecidedAt >= since && (u.DecidedByUserId == null || u.DecidedByUserId != userId) && DecisionStatuses.Contains(u.Status) orderby u.DecidedAt descending, u.Id descending select new NotificationUpliftDecisionCandidate { UpliftId = u.Id, WorkOrderId = w.Id, WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber, Status = u.Status, Amount = u.RequestedNTE, DecisionNote = u.DecisionNote, DecidedAt = u.DecidedAt!.Value }; return await candidates.Take(limit).ToListAsync(cancellationToken); } public async Task 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() : 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 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 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 CreatedAtOrBefore(IQueryable 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 CreatedAfter(IQueryable 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 ProjectSla(IQueryable 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 ScopedWorkOrders(int? accountId) { var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); return accountId is int id ? WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, id) : workOrders; } private IQueryable OpenWorkOrders(NotificationFeedScope scope) { var workOrders = ScopedWorkOrders(scope.AccountId); if (scope.DispatcherId != null) workOrders = workOrders.Where(w => w.AssignTo == scope.DispatcherId); return workOrders.Where(w => w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled && (w.LifecycleStatus != null || ((w.LegacyStatus ?? w.Status) != "Done" && (w.LegacyStatus ?? w.Status) != "Completed" && (w.LegacyStatus ?? w.Status) != "Complete" && (w.LegacyStatus ?? w.Status) != "Closed" && (w.LegacyStatus ?? w.Status) != "Canceled" && (w.LegacyStatus ?? w.Status) != "Cancelled"))); } private static async Task PageAsync( IQueryable candidates, int limit, CancellationToken cancellationToken) { var total = await candidates.CountAsync(cancellationToken); if (total == 0) return new NotificationCandidatePage(); var items = await candidates .OrderByDescending(w => w.ScheduledDate) .ThenByDescending(w => w.Id) .Take(limit) .Select(w => new NotificationWorkOrderCandidate { Id = w.Id, Number = w.InternalWONumber ?? w.WorkerOrderNumber, ScheduledDate = w.ScheduledDate!.Value }) .ToListAsync(cancellationToken); return new NotificationCandidatePage { Total = total, Items = items }; } } }