shoc-backend/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs
Alexandre Brandizzi da0b29f769 feat(notifications): serve the Notification Center feed grouped by reason
Adds GET /api/notifications, a per-user read model derived from live
work-order state: Unassigned (grouped, High), No Vendor and Aveta Missing
(per work order, Medium) and Vendor Conflict, account-scoped from claims
and ordered by section severity with a fixed reason tie-break.
2026-09-18 12:40:37 -03:00

112 lines
5.2 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" };
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 non-terminal dispatch that
// points at the work order directly or links it through DispatchWorkOrders.
var candidates = OpenWorkOrders(scope)
.Where(w => w.ScheduledDate.HasValue
&& w.ScheduledDate.Value <= cutoff
&& !_context.Dispatches.Any(d => d.WorkOrderId == w.Id
&& !TerminalDispatchStatuses.Contains(d.Status ?? ""))
&& !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id
&& link.Dispatch != null
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")));
return PageAsync(candidates, limit, cancellationToken);
}
private IQueryable<WorkOrder> OpenWorkOrders(NotificationFeedScope scope)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (scope.AccountId is int accountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, 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 };
}
}
}