2026-09-18 12:40:33 -03:00
|
|
|
|
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" };
|
|
|
|
|
|
|
2026-09-18 13:15:33 -03:00
|
|
|
|
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
|
|
|
|
|
|
private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
|
|
|
|
|
|
|
2026-09-25 11:16:21 -03:00
|
|
|
|
private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" };
|
|
|
|
|
|
|
2026-09-18 13:15:33 -03:00
|
|
|
|
private const string AssignToAuditField = "AssignTo";
|
|
|
|
|
|
private const string LegacyAssignedToAuditField = "AssignedTo";
|
|
|
|
|
|
|
2026-09-18 12:40:33 -03:00
|
|
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
|
|
|
|
|
|
|
|
public NotificationFeedDataService(ApplicationDbContext context)
|
|
|
|
|
|
{
|
|
|
|
|
|
_context = context;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<UnassignedWorkOrdersSummary> 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<NotificationCandidatePage> 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<NotificationCandidatePage> GetNoVendorAsync(
|
|
|
|
|
|
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
2026-09-18 12:59:56 -03:00
|
|
|
|
// "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.
|
2026-09-18 12:40:33 -03:00
|
|
|
|
var candidates = OpenWorkOrders(scope)
|
|
|
|
|
|
.Where(w => w.ScheduledDate.HasValue
|
|
|
|
|
|
&& w.ScheduledDate.Value <= cutoff
|
|
|
|
|
|
&& !_context.Dispatches.Any(d => d.WorkOrderId == w.Id
|
2026-09-18 12:59:56 -03:00
|
|
|
|
&& (d.IsDeleted == null || d.IsDeleted == false)
|
2026-09-18 12:40:33 -03:00
|
|
|
|
&& !TerminalDispatchStatuses.Contains(d.Status ?? ""))
|
|
|
|
|
|
&& !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id
|
|
|
|
|
|
&& link.Dispatch != null
|
2026-09-18 12:59:56 -03:00
|
|
|
|
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
2026-09-18 12:40:33 -03:00
|
|
|
|
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")));
|
|
|
|
|
|
|
|
|
|
|
|
return PageAsync(candidates, limit, cancellationToken);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 13:15:33 -03:00
|
|
|
|
public async Task<IReadOnlyList<NotificationAssignmentCandidate>> 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<IReadOnlyList<NotificationCommentCandidate>> 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<IReadOnlyList<NotificationUpliftDecisionCandidate>> 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 11:16:21 -03:00
|
|
|
|
public async Task<NotificationSlaCandidates> 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<NotificationSlaCandidate>()
|
|
|
|
|
|
: 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<NotificationSlaCandidate?> 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<WorkOrder> 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<WorkOrder> CreatedAtOrBefore(IQueryable<WorkOrder> 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<WorkOrder> CreatedAfter(IQueryable<WorkOrder> 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<NotificationSlaCandidate> ProjectSla(IQueryable<WorkOrder> 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)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-18 13:15:33 -03:00
|
|
|
|
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
|
2026-09-18 12:40:33 -03:00
|
|
|
|
{
|
|
|
|
|
|
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
2026-09-18 13:15:33 -03:00
|
|
|
|
return accountId is int id
|
|
|
|
|
|
? WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, id)
|
|
|
|
|
|
: workOrders;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private IQueryable<WorkOrder> OpenWorkOrders(NotificationFeedScope scope)
|
|
|
|
|
|
{
|
|
|
|
|
|
var workOrders = ScopedWorkOrders(scope.AccountId);
|
2026-09-18 12:40:33 -03:00
|
|
|
|
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<NotificationCandidatePage> PageAsync(
|
|
|
|
|
|
IQueryable<WorkOrder> 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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|