shoc-backend/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs

227 lines
11 KiB
C#
Raw Normal View History

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 const string AssignToAuditField = "AssignTo";
private const string LegacyAssignedToAuditField = "AssignedTo";
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)
{
// "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<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);
}
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
return accountId is int id
? WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, id)
: workOrders;
}
private IQueryable<WorkOrder> 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<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 };
}
}
}