mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
226 lines
11 KiB
C#
226 lines
11 KiB
C#
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 };
|
|
}
|
|
}
|
|
}
|