shoc-backend/SeaHaven.Services/Implementation/NotificationFeedService.cs
Alexandre Brandizzi aad3facaf1 fix(notifications): ignore soft-deleted dispatches and cap conflicts by recency
No Vendor treated any non-terminal dispatch as an assigned vendor without
checking IsDeleted, so a soft-deleted dispatch hid the work order from both
No Vendor and Vendor Conflict (the conflict query already drops deleted
dispatches). GetNoVendorAsync and the vendor-reminder assigned-work-order rule
now skip soft-deleted dispatches, keeping the asserted parity between the feed
and the reminders.

Vendor Conflict applied the section cap in vendor-sweep order, so when overlaps
exceeded the limit newer conflicts could be dropped while Count still counted
every work order. VendorConflictsAsync now orders pairs by recency before the
cap, matching the other per-work-order sections.
2026-09-18 12:59:56 -03:00

187 lines
9.3 KiB
C#

using System.Security.Claims;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class NotificationFeedService : INotificationFeedService
{
/// <summary>Most items returned per per-work-order section; section counts stay exact.</summary>
public const int SectionItemLimit = 50;
private static readonly TimeSpan NoVendorLeadTime = TimeSpan.FromHours(48);
private readonly INotificationFeedDataService _data;
private readonly IVendorOperationsDataService _vendorData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly TimeProvider _timeProvider;
public NotificationFeedService(
INotificationFeedDataService data,
IVendorOperationsDataService vendorData,
IWorkOrderAccountResolver accountResolver,
TimeProvider timeProvider)
{
_data = data;
_vendorData = vendorData;
_accountResolver = accountResolver;
_timeProvider = timeProvider;
}
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
var scope = new NotificationFeedScope(accountId, dispatcherId);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
if (seesUnassigned)
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
sections.Add(await AvetaMissingAsync(scope, now, cancellationToken));
sections.Add(await VendorConflictsAsync(scope, cancellationToken));
return new NotificationFeedDto
{
GeneratedAt = now,
Sections = NotificationFeedOrdering.Order(sections)
};
}
// Dispatchers see their own work orders. Roles that already see every dispatcher's
// work on the dashboard see the whole account, including work nobody owns yet.
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
{
if (user.IsInRole("Dispatcher"))
{
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return (dispatcherId, false);
}
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
return (null, true);
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> UnassignedAsync(
int? accountId, DateTime now, CancellationToken cancellationToken)
{
var summary = await _data.GetUnassignedSummaryAsync(accountId, cancellationToken);
if (summary.Count == 0)
return (NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>());
var item = new NotificationItemDto
{
Id = NotificationReasons.Unassigned,
Reason = NotificationReasons.Unassigned,
Severity = NotificationSeverities.High,
Title = summary.Count == 1
? "1 work order is unassigned"
: $"{summary.Count} work orders are unassigned",
Count = summary.Count,
TriggeredAt = summary.LatestCreatedAt ?? now,
Target = new NotificationTargetDto
{
Kind = NotificationTargetKinds.Queue,
Queue = NotificationQueues.Unassigned
}
};
return (NotificationReasons.Unassigned, summary.Count, new[] { item });
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> NoVendorAsync(
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
{
var page = await _data.GetNoVendorAsync(scope, now.Add(NoVendorLeadTime), SectionItemLimit, cancellationToken);
var items = page.Items.Select(candidate => new NotificationItemDto
{
Id = $"no-vendor-{candidate.Id}",
Reason = NotificationReasons.NoVendor,
Severity = NotificationSeverities.Medium,
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} starts within 48h with no vendor assigned",
Count = 1,
TriggeredAt = candidate.ScheduledDate.Subtract(NoVendorLeadTime),
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
}).ToList();
return (NotificationReasons.NoVendor, page.Total, items);
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> AvetaMissingAsync(
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
{
var today = NotificationBusinessTime.Today(now);
var page = await _data.GetAvetaMissingAsync(scope, today, SectionItemLimit, cancellationToken);
var items = page.Items.Select(candidate => new NotificationItemDto
{
Id = $"aveta-missing-{candidate.Id}",
Reason = NotificationReasons.AvetaMissing,
Severity = NotificationSeverities.Medium,
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} is missing the Aveta document",
Count = 1,
// The one-day window opens the day before the scheduled date.
TriggeredAt = candidate.ScheduledDate.Date.AddDays(-1),
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Extras),
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
}).ToList();
return (NotificationReasons.AvetaMissing, page.Total, items);
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> VendorConflictsAsync(
NotificationFeedScope scope, CancellationToken cancellationToken)
{
var dispatches = await _vendorData.GetActiveScheduledDispatchesAsync(scope.AccountId, cancellationToken);
var pairs = VendorConflictPairs.Build(dispatches)
.Where(pair => scope.DispatcherId == null
|| pair.Left.AssignTo == scope.DispatcherId
|| pair.Right.AssignTo == scope.DispatcherId)
.ToList();
var workOrderCount = pairs
.SelectMany(pair => new[] { pair.Left.WorkOrderId, pair.Right.WorkOrderId })
.Distinct()
.Count();
// Cap by recency like the other sections: keep the newest overlaps so a section that
// exceeds the limit never drops a more recent conflict in favour of an older one.
var items = pairs
.OrderByDescending(pair => pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start)
.ThenByDescending(pair => pair.Left.Id)
.ThenByDescending(pair => pair.Right.Id)
.Take(SectionItemLimit)
.Select(pair => new NotificationItemDto
{
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
Reason = NotificationReasons.VendorConflict,
Severity = NotificationSeverities.Medium,
Title = $"{(string.IsNullOrWhiteSpace(pair.Left.VendorName) ? "Vendor" : pair.Left.VendorName)} has overlapping appointments",
Count = 2,
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
Target = WorkOrderTarget(pair.Left.WorkOrderId, NotificationWorkOrderTabs.Info),
WorkOrders = new[]
{
new NotificationWorkOrderRefDto { Id = pair.Left.WorkOrderId, Number = pair.Left.WorkOrderNumber },
new NotificationWorkOrderRefDto { Id = pair.Right.WorkOrderId, Number = pair.Right.WorkOrderNumber }
}
}).ToList();
return (NotificationReasons.VendorConflict, workOrderCount, items);
}
private static NotificationTargetDto WorkOrderTarget(int workOrderId, string tab) => new()
{
Kind = NotificationTargetKinds.WorkOrder,
WorkOrderId = workOrderId,
Tab = tab
};
private static string WorkOrderLabel(int id, string? number)
=> $"WO #{(string.IsNullOrWhiteSpace(number) ? id.ToString() : number)}";
}
}