From da0b29f76927bea468b93b629e14257ed872d6ea Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 18 Sep 2026 12:40:33 -0300 Subject: [PATCH 1/3] 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. --- .../NotificationFeedServiceTests.cs | 530 ++++++++++++++++++ .../Controllers/NotificationsController.cs | 37 ++ .../NotificationFeedDataService.cs | 112 ++++ .../VendorOperationsDataService.cs | 25 +- .../INotificationFeedDataService.cs | 29 + .../IVendorOperationsDataService.cs | 2 + .../Models/NotificationFeedReadModels.cs | 27 + .../Models/VendorOperationsReadModels.cs | 1 + .../DTOs/NotificationFeedDTOs.cs | 131 +++++ .../Helpers/NotificationFeedOrdering.cs | 57 ++ .../Helpers/VendorConflictPairs.cs | 34 ++ .../Implementation/NotificationFeedService.cs | 180 ++++++ .../Implementation/VendorOperationsService.cs | 26 +- .../Interfaces/INotificationFeedService.cs | 14 + 14 files changed, 1176 insertions(+), 29 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/NotificationsController.cs create mode 100644 SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs create mode 100644 SeaHaven.DataServices/Models/NotificationFeedReadModels.cs create mode 100644 SeaHaven.Services/DTOs/NotificationFeedDTOs.cs create mode 100644 SeaHaven.Services/Helpers/NotificationFeedOrdering.cs create mode 100644 SeaHaven.Services/Helpers/VendorConflictPairs.cs create mode 100644 SeaHaven.Services/Implementation/NotificationFeedService.cs create mode 100644 SeaHaven.Services/Interfaces/INotificationFeedService.cs diff --git a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs new file mode 100644 index 0000000..cb702a4 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs @@ -0,0 +1,530 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class NotificationFeedServiceTests +{ + // 12:00 in New York, so the business date is 2026-09-18. + private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Today = new(2026, 9, 18); + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(Now); + } + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static NotificationFeedService NewService(ApplicationDbContext context) + { + var resolver = new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)); + return new NotificationFeedService( + new NotificationFeedDataService(context), + new VendorOperationsDataService(context, new DispatchDataService(context)), + resolver, + new FixedTimeProvider()); + } + + private static ClaimsPrincipal User(int accountId, string role, string userId = "user-1") + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static ClaimsPrincipal OrgWideAdmin() + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + new Claim(ClaimTypes.NameIdentifier, "admin-1"), + new Claim(ClaimTypes.Role, "Admin") + }, "test")); + + private static WorkOrder Open(int accountId, string? assignTo = null, DateTime? scheduled = null, string? number = null) + => new() + { + AccountId = accountId, + AssignTo = assignTo, + ScheduledDate = scheduled, + InternalWONumber = number, + LifecycleStatus = LifecycleStatus.Scheduled, + CreatedDate = Now.AddDays(-3) + }; + + private static WorkOrder AvetaDue(int accountId, string assignTo, DateTime scheduled, string number) + { + var workOrder = Open(accountId, assignTo, scheduled, number); + workOrder.AvetaRequired = true; + return workOrder; + } + + private static NotificationSectionDto Section(NotificationFeedDto feed, string reason) + => feed.Sections.Single(section => section.Reason == reason); + + private static void AddActiveDispatch(ApplicationDbContext context, WorkOrder workOrder) + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + context.Vendors.Add(vendor); + context.SaveChanges(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledDate + }); + context.SaveChanges(); + } + + [Fact] + public async Task Unassigned_GroupsEveryOpenUnassignedWorkOrderIntoOneHighItem() + { + using var context = NewContext(); + var newest = Open(1); + newest.CreatedDate = Now.AddHours(-1); + var completed = Open(1); + completed.LifecycleStatus = LifecycleStatus.Completed; + var canceled = Open(1); + canceled.LifecycleStatus = LifecycleStatus.Canceled; + var legacyDone = Open(1); + legacyDone.LifecycleStatus = null; + legacyDone.Status = "Done"; + var template = Open(1); + template.istemplate = true; + var deleted = Open(1); + deleted.IsDeleted = true; + context.workOrders.AddRange( + Open(1), Open(1, assignTo: ""), newest, Open(1, assignTo: "dispatcher-1"), + completed, canceled, legacyDone, template, deleted); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.Unassigned); + section.Label.Should().Be("Unassigned"); + section.Count.Should().Be(3); + var item = section.Items.Should().ContainSingle().Subject; + item.Id.Should().Be("unassigned"); + item.Severity.Should().Be(NotificationSeverities.High); + item.RowType.Should().Be(NotificationRowTypes.Dismissable); + item.Title.Should().Be("3 work orders are unassigned"); + item.Count.Should().Be(3); + item.TriggeredAt.Should().Be(newest.CreatedDate); + item.Target.Kind.Should().Be(NotificationTargetKinds.Queue); + item.Target.Queue.Should().Be(NotificationQueues.Unassigned); + } + + [Fact] + public async Task Unassigned_CountFollowsAssignmentAndSectionDisappearsAtZero() + { + using var context = NewContext(); + var first = Open(1); + var second = Open(1); + context.workOrders.AddRange(first, second); + await context.SaveChangesAsync(); + var service = NewService(context); + + first.AssignTo = "dispatcher-1"; + await context.SaveChangesAsync(); + var afterOne = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None); + var item = Section(afterOne, NotificationReasons.Unassigned).Items.Single(); + item.Title.Should().Be("1 work order is unassigned"); + item.Id.Should().Be("unassigned", "the id stays stable so a session dismissal survives count changes"); + + second.AssignTo = "dispatcher-2"; + await context.SaveChangesAsync(); + var afterAll = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None); + afterAll.Sections.Should().NotContain(section => section.Reason == NotificationReasons.Unassigned); + } + + [Theory] + [InlineData("Admin")] + [InlineData("Manager")] + [InlineData("Scheduler")] + public async Task Unassigned_IsShownToRolesThatSeeEveryDispatcher(string role) + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, role), CancellationToken.None); + + Section(feed, NotificationReasons.Unassigned).Count.Should().Be(1); + } + + [Fact] + public async Task Unassigned_IsNotShownToDispatchers() + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); + + feed.Sections.Should().BeEmpty(); + } + + [Fact] + public async Task AvetaMissing_OnlyFlagsOpenRequiredWorkOrdersInTheOneDayWindowWithoutTheDocument() + { + using var context = NewContext(); + var dueToday = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-TODAY"); + var dueTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(1).AddHours(9), "A-TOMORROW"); + var withDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(10), "A-HAS-DOC"); + var withDeletedDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(11), "A-DELETED-DOC"); + var dayAfterTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(2).AddHours(9), "A-LATER"); + var yesterday = AvetaDue(1, "dispatcher-1", Today.AddDays(-1).AddHours(9), "A-PAST"); + var completed = AvetaDue(1, "dispatcher-1", Today.AddHours(12), "A-DONE"); + completed.LifecycleStatus = LifecycleStatus.Completed; + var notRequired = Open(1, "dispatcher-1", Today.AddHours(13), "A-NOT-REQUIRED"); + context.workOrders.AddRange( + dueToday, dueTomorrow, withDocument, withDeletedDocument, dayAfterTomorrow, yesterday, completed, notRequired); + await context.SaveChangesAsync(); + context.workOrderAttachments.AddRange( + new WorkOrderAttachments { WorkorderId = withDocument.Id, Category = WorkOrderMediaCategory.Aveta }, + new WorkOrderAttachments { WorkorderId = withDeletedDocument.Id, Category = WorkOrderMediaCategory.Aveta, IsDeleted = true }, + new WorkOrderAttachments { WorkorderId = dueToday.Id, Category = WorkOrderMediaCategory.Extra }); + await context.SaveChangesAsync(); + AddActiveDispatch(context, dueToday); + AddActiveDispatch(context, dueTomorrow); + AddActiveDispatch(context, withDeletedDocument); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.AvetaMissing); + section.Label.Should().Be("Aveta Missing"); + section.Count.Should().Be(3); + section.Items.Select(item => item.WorkOrders.Single().Number) + .Should().Equal("A-TOMORROW", "A-TODAY", "A-DELETED-DOC"); + var tomorrowItem = section.Items[0]; + tomorrowItem.Id.Should().Be($"aveta-missing-{dueTomorrow.Id}"); + tomorrowItem.Severity.Should().Be(NotificationSeverities.Medium); + tomorrowItem.Title.Should().Be("WO #A-TOMORROW is missing the Aveta document"); + tomorrowItem.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder); + tomorrowItem.Target.WorkOrderId.Should().Be(dueTomorrow.Id); + tomorrowItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Extras); + } + + [Fact] + public async Task AvetaMissing_ClearsOnceTheDocumentIsAttached() + { + using var context = NewContext(); + var workOrder = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-1"); + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + AddActiveDispatch(context, workOrder); + var service = NewService(context); + var dispatcher = User(1, "Dispatcher", "dispatcher-1"); + + (await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections + .Should().ContainSingle(section => section.Reason == NotificationReasons.AvetaMissing); + + context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = workOrder.Id, Category = WorkOrderMediaCategory.Aveta }); + await context.SaveChangesAsync(); + + (await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections.Should().BeEmpty(); + } + + [Fact] + public async Task NoVendor_MatchesTheVendorReminderRuleWithinFortyEightHours() + { + using var context = NewContext(); + var noVendor = Open(1, "dispatcher-1", Now.AddHours(24), "NV-1"); + var withDispatch = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DISPATCHED"); + var withLinkedDispatch = Open(1, "dispatcher-1", Now.AddHours(22), "NV-LINKED"); + var withFinishedDispatch = Open(1, "dispatcher-1", Now.AddHours(30), "NV-FINISHED"); + var tooFar = Open(1, "dispatcher-1", Now.AddHours(72), "NV-LATER"); + context.workOrders.AddRange(noVendor, withDispatch, withLinkedDispatch, withFinishedDispatch, tooFar); + await context.SaveChangesAsync(); + AddActiveDispatch(context, withDispatch); + var vendor = new Vendor { CompanyName = "Linked", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + var multi = new Dispatch { VendorId = vendor.Id, WorkOrderId = withDispatch.Id, Status = "Sent" }; + var finished = new Dispatch { VendorId = vendor.Id, WorkOrderId = withFinishedDispatch.Id, Status = "Completed" }; + context.Dispatches.AddRange(multi, finished); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = multi.Id, WorkOrderId = withLinkedDispatch.Id }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.NoVendor); + section.Label.Should().Be("No Vendor"); + section.Items.Select(item => item.WorkOrders.Single().Number).Should().Equal("NV-FINISHED", "NV-1"); + var item = section.Items.Single(row => row.Id == $"no-vendor-{noVendor.Id}"); + item.Severity.Should().Be(NotificationSeverities.Medium); + item.Title.Should().Be("WO #NV-1 starts within 48h with no vendor assigned"); + item.TriggeredAt.Should().Be(noVendor.ScheduledDate!.Value.AddHours(-48)); + item.Target.WorkOrderId.Should().Be(noVendor.Id); + } + + [Fact] + public async Task NoVendor_FlagsTheSameWorkOrdersAsTheVendorOperationsReminders() + { + using var context = NewContext(); + var first = Open(1, "dispatcher-1", Now.AddHours(10), "P-1"); + var second = Open(2, "dispatcher-2", Now.AddHours(40), "P-2"); + var dispatched = Open(1, "dispatcher-1", Now.AddHours(12), "P-3"); + context.workOrders.AddRange(first, second, dispatched); + await context.SaveChangesAsync(); + AddActiveDispatch(context, dispatched); + var vendorService = new VendorOperationsService( + new VendorOperationsDataService(context, new DispatchDataService(context)), new FixedTimeProvider()); + + var reminders = await vendorService.GetNotificationsAsync(CancellationToken.None); + var feed = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None); + + var reminderIds = reminders.Items.OfType().Select(item => item.Id); + Section(feed, NotificationReasons.NoVendor).Items.Select(item => item.Id) + .Should().BeEquivalentTo(reminderIds); + } + + [Fact] + public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency() + { + using var context = NewContext(); + var aveta = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "AV-1"); + var earlierNoVendor = Open(1, "dispatcher-1", Now.AddHours(5), "NV-EARLY"); + var laterNoVendor = Open(1, "dispatcher-1", Now.AddHours(30), "NV-LATE"); + context.workOrders.AddRange(aveta, earlierNoVendor, laterNoVendor, Open(1)); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None); + + feed.GeneratedAt.Should().Be(Now); + feed.Sections.Select(section => section.Reason).Should().Equal( + NotificationReasons.Unassigned, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing); + feed.Sections.Select(section => section.Severity).Should().Equal( + NotificationSeverities.High, NotificationSeverities.Medium, NotificationSeverities.Medium); + var noVendorNumbers = Section(feed, NotificationReasons.NoVendor).Items + .Select(item => item.WorkOrders.Single().Number); + noVendorNumbers.Should().ContainInOrder("NV-LATE", "NV-EARLY"); + } + + [Fact] + public void Ordering_BreaksEqualSeverityTiesByReasonOrderAndPutsHigherSeverityFirst() + { + static NotificationItemDto Item(string id, string reason, string severity, int minutes) => new() + { + Id = id, + Reason = reason, + Severity = severity, + Count = 1, + TriggeredAt = Now.AddMinutes(minutes) + }; + + var sections = NotificationFeedOrdering.Order(new (string, int, IReadOnlyList)[] + { + (NotificationReasons.AvetaMissing, 1, new[] { Item("a", NotificationReasons.AvetaMissing, NotificationSeverities.Medium, 0) }), + (NotificationReasons.Activity, 2, new[] + { + Item("old-low", NotificationReasons.Activity, NotificationSeverities.Low, -10), + Item("new-low", NotificationReasons.Activity, NotificationSeverities.Low, 10), + Item("high", NotificationReasons.Activity, NotificationSeverities.High, -20) + }), + (NotificationReasons.Unassigned, 0, Array.Empty()), + (NotificationReasons.NoVendor, 1, new[] { Item("n", NotificationReasons.NoVendor, NotificationSeverities.Medium, 0) }) + }); + + sections.Select(section => section.Reason).Should().Equal( + NotificationReasons.Activity, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing); + sections[0].Items.Select(item => item.Id).Should().Equal("high", "new-low", "old-low"); + sections[0].Label.Should().Be("Recent activity"); + } + + [Fact] + public async Task Dispatcher_OnlySeesTheirOwnWorkOrders() + { + using var context = NewContext(); + context.workOrders.AddRange( + AvetaDue(1, "dispatcher-1", Today.AddHours(9), "MINE"), + AvetaDue(1, "dispatcher-2", Today.AddHours(9), "THEIRS"), + Open(1, "dispatcher-2", Now.AddHours(5), "THEIRS-NV")); + await context.SaveChangesAsync(); + foreach (var workOrder in context.workOrders.Where(w => w.AvetaRequired).ToList()) + AddActiveDispatch(context, workOrder); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + feed.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.AvetaMissing); + Section(feed, NotificationReasons.AvetaMissing).Items.Single().WorkOrders.Single().Number.Should().Be("MINE"); + } + + [Fact] + public async Task CrossTenant_AccountUserNeverSeesAnotherAccountsWorkOrders() + { + using var context = NewContext(); + var otherAveta = AvetaDue(2, "dispatcher-1", Today.AddHours(9), "OTHER-AV"); + var otherNoVendor = Open(2, "dispatcher-1", Now.AddHours(5), "OTHER-NV"); + var otherUnassigned = Open(2); + var ownUnassigned = Open(1); + context.workOrders.AddRange(otherAveta, otherNoVendor, otherUnassigned, ownUnassigned); + await context.SaveChangesAsync(); + AddActiveDispatch(context, otherAveta); + var vendor = context.Vendors.First(); + var overlapping = Open(2, "dispatcher-1", Now.AddHours(1), "OTHER-OVERLAP"); + context.workOrders.Add(overlapping); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = overlapping.Id, Status = "Sent", ScheduledDate = otherAveta.ScheduledDate }); + await context.SaveChangesAsync(); + + var admin = await NewService(context).GetFeedAsync(User(1, "Admin"), CancellationToken.None); + var dispatcher = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + admin.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.Unassigned); + Section(admin, NotificationReasons.Unassigned).Count.Should().Be(1); + dispatcher.Sections.Should().BeEmpty(); + var orgWide = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None); + orgWide.Sections.Select(section => section.Reason).Should().Contain(new[] + { + NotificationReasons.Unassigned, NotificationReasons.NoVendor, + NotificationReasons.AvetaMissing, NotificationReasons.VendorConflict + }); + } + + [Fact] + public async Task VendorConflict_KeepsOverlappingAppointmentsAsTheirOwnSection() + { + using var context = NewContext(); + var mine = Open(1, "dispatcher-1", Now.AddDays(3), "C-MINE"); + var theirs = Open(1, "dispatcher-2", Now.AddDays(3), "C-THEIRS"); + context.workOrders.AddRange(mine, theirs); + await context.SaveChangesAsync(); + AddActiveDispatch(context, mine); + var vendor = context.Vendors.Single(); + context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = theirs.Id, Status = "Sent", ScheduledDate = theirs.ScheduledDate }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + var unrelated = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-3"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.VendorConflict); + section.Label.Should().Be("Vendor Conflict"); + section.Count.Should().Be(2); + var item = section.Items.Single(); + item.Title.Should().Be("Gateway has overlapping appointments"); + item.WorkOrders.Select(workOrder => workOrder.Number).Should().BeEquivalentTo("C-MINE", "C-THEIRS"); + unrelated.Sections.Should().BeEmpty(); + } + + [Fact] + public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder() + { + using var context = NewContext(); + var total = NotificationFeedService.SectionItemLimit + 5; + for (var index = 0; index < total; index++) + context.workOrders.Add(Open(1, "dispatcher-1", Now.AddHours(1).AddMinutes(index), $"B-{index}")); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.NoVendor); + section.Count.Should().Be(total); + section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit); + } + + [Fact] + public async Task MissingAccountScope_FailsClosed() + { + using var context = NewContext(); + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user-1"), + new Claim(ClaimTypes.Role, "Admin") + }, "test")); + + var act = () => NewService(context).GetFeedAsync(user, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task UnknownRole_FailsClosed() + { + using var context = NewContext(); + + var act = () => NewService(context).GetFeedAsync(User(1, "Vendor"), CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task Cancellation_ReachesTheFeedQueries() + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = () => NewService(context).GetFeedAsync(User(1, "Admin"), cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Controller_MapsForbiddenScopeTo403() + { + using var context = NewContext(); + var controller = new NotificationsController(NewService(context)) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = User(1, "Vendor") } + } + }; + + var result = await controller.GetFeed(CancellationToken.None); + + result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task Controller_ReturnsTheFeedForAnAuthorizedUser() + { + using var context = NewContext(); + context.workOrders.Add(Open(1)); + await context.SaveChangesAsync(); + var controller = new NotificationsController(NewService(context)) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = User(1, "Admin") } + } + }; + + var result = await controller.GetFeed(CancellationToken.None); + + var feed = result.Should().BeOfType().Which.Value.Should().BeOfType().Subject; + feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned); + } + + [Fact] + public void Controller_RequiresAnAuthenticatedCaller() + { + typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) + .Should().NotBeEmpty(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/NotificationsController.cs b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs new file mode 100644 index 0000000..5eff188 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/NotificationsController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/notifications")] + public class NotificationsController : ControllerBase + { + private readonly INotificationFeedService _feedService; + + public NotificationsController(INotificationFeedService feedService) + { + _feedService = feedService; + } + + [HttpGet] + public async Task GetFeed(CancellationToken cancellationToken) + { + try + { + return Ok(await _feedService.GetFeedAsync(User, cancellationToken)); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } + } +} diff --git a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs new file mode 100644 index 0000000..7741550 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs @@ -0,0 +1,112 @@ +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 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 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 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 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 PageAsync( + IQueryable 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 }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs index e1eefc3..0468f43 100644 --- a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs @@ -56,14 +56,23 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } - public async Task> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken) + public Task> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken) + => GetActiveScheduledDispatchesAsync(null, cancellationToken); + + public async Task> GetActiveScheduledDispatchesAsync( + int? accountId, CancellationToken cancellationToken) { - var direct = _context.Dispatches.AsNoTracking() + var directDispatches = _context.Dispatches.AsNoTracking() .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") && (dispatch.IsDeleted == null || dispatch.IsDeleted == false) && dispatch.WorkOrderId.HasValue && (dispatch.ScheduledDate.HasValue - || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))) + || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))); + if (accountId is int directAccountId) + directDispatches = directDispatches.Where(dispatch => + dispatch.WorkOrder != null && dispatch.WorkOrder.AccountId == directAccountId); + + var direct = directDispatches .Select(dispatch => new ScheduledDispatchReadModel { Id = dispatch.Id, @@ -71,18 +80,23 @@ namespace SeaHaven.DataServices.Implementation VendorName = dispatch.Vendor!.CompanyName, WorkOrderId = dispatch.WorkOrderId!.Value, WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, + AssignTo = dispatch.WorkOrder.AssignTo, Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, End = dispatch.WorkOrder.ScheduledEnd ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) }); - var linked = _context.DispatchWorkOrders.AsNoTracking() + var linkedDispatches = _context.DispatchWorkOrders.AsNoTracking() .Where(link => link.Dispatch != null && link.WorkOrder != null && link.Dispatch.WorkOrderId != link.WorkOrderId && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) - && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)) + && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)); + if (accountId is int linkedAccountId) + linkedDispatches = linkedDispatches.Where(link => link.WorkOrder!.AccountId == linkedAccountId); + + var linked = linkedDispatches .Select(link => new ScheduledDispatchReadModel { Id = link.DispatchId, @@ -90,6 +104,7 @@ namespace SeaHaven.DataServices.Implementation VendorName = link.Dispatch.Vendor!.CompanyName, WorkOrderId = link.WorkOrderId, WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, + AssignTo = link.WorkOrder.AssignTo, Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, End = link.WorkOrder.ScheduledEnd ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) diff --git a/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs new file mode 100644 index 0000000..d96e59d --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs @@ -0,0 +1,29 @@ +using SeaHaven.DataServices.Models; + +namespace SeaHaven.DataServices.Interfaces +{ + /// + /// Live work-order conditions behind the notification feed. Every query applies the + /// base scope (no templates, no deleted rows), the caller's account scope, and the + /// shared open rule (not Completed/Canceled, including legacy terminal statuses). + /// + public interface INotificationFeedDataService + { + /// Open work orders with no dispatcher assigned in (null is org-wide). + Task GetUnassignedSummaryAsync(int? accountId, CancellationToken cancellationToken); + + /// + /// Open, Aveta-required work orders scheduled on or the next day with no + /// Aveta document attached. Items are the latest scheduled. + /// + Task GetAvetaMissingAsync( + NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken); + + /// + /// Open work orders scheduled at or before with no active vendor dispatch, + /// direct or linked. Items are the latest scheduled. + /// + Task GetNoVendorAsync( + NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs index b8c71c1..48e3105 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs @@ -8,6 +8,8 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken); Task> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection excludeIds, CancellationToken cancellationToken); Task> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken); + /// Same windows limited to work orders of ; null is org-wide. + Task> GetActiveScheduledDispatchesAsync(int? accountId, CancellationToken cancellationToken); Task> GetActiveVendorsAsync(CancellationToken cancellationToken); Task> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken); Task> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken); diff --git a/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs new file mode 100644 index 0000000..9d6f0ae --- /dev/null +++ b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs @@ -0,0 +1,27 @@ +namespace SeaHaven.DataServices.Models; + +/// +/// Server-derived feed scope. null is org-wide; +/// null means every dispatcher's work orders. +/// +public sealed record NotificationFeedScope(int? AccountId, string? DispatcherId); + +public sealed class UnassignedWorkOrdersSummary +{ + public int Count { get; init; } + public DateTime? LatestCreatedAt { get; init; } +} + +public sealed class NotificationWorkOrderCandidate +{ + public int Id { get; init; } + public string? Number { get; init; } + public DateTime ScheduledDate { get; init; } +} + +/// A bounded slice of candidates plus the unbounded total they were taken from. +public sealed class NotificationCandidatePage +{ + public int Total { get; init; } + public IReadOnlyList Items { get; init; } = Array.Empty(); +} diff --git a/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs b/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs index 2eb630e..ef0baf5 100644 --- a/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs +++ b/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs @@ -7,6 +7,7 @@ public sealed class ScheduledDispatchReadModel public string? VendorName { get; set; } public int WorkOrderId { get; set; } public string? WorkOrderNumber { get; set; } + public string? AssignTo { get; set; } public DateTime Start { get; set; } public DateTime End { get; set; } } diff --git a/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs new file mode 100644 index 0000000..d6aacc7 --- /dev/null +++ b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs @@ -0,0 +1,131 @@ +namespace SeaHaven.Services.DTOs +{ + /// + /// Feed reasons in their fixed tie-break order. Every reason the Notification Center + /// can show is listed here so a new producer only adds items, never a contract change. + /// + public static class NotificationReasons + { + public const string SeverityCritical = "severity-critical"; + public const string Sla = "sla"; + public const string Mentions = "mentions"; + public const string Unassigned = "unassigned"; + public const string DueSoon = "due-soon"; + public const string NoVendor = "no-vendor"; + public const string AvetaMissing = "aveta-missing"; + public const string Activity = "activity"; + public const string VendorConflict = "vendor-conflict"; + + public static readonly IReadOnlyList Order = new[] + { + SeverityCritical, Sla, Mentions, Unassigned, DueSoon, NoVendor, AvetaMissing, Activity, VendorConflict + }; + + public static readonly IReadOnlyDictionary Labels = new Dictionary + { + [SeverityCritical] = "Critical severity", + [Sla] = "SLA at Risk", + [Mentions] = "Mentions", + [Unassigned] = "Unassigned", + [DueSoon] = "Due Soon", + [NoVendor] = "No Vendor", + [AvetaMissing] = "Aveta Missing", + [Activity] = "Recent activity", + [VendorConflict] = "Vendor Conflict" + }; + } + + public static class NotificationSeverities + { + public const string Critical = "Critical"; + public const string High = "High"; + public const string Medium = "Medium"; + public const string Low = "Low"; + + public static int Rank(string severity) => severity switch + { + Critical => 3, + High => 2, + Medium => 1, + _ => 0 + }; + } + + /// + /// How a row leaves the feed. rows are hidden client-side for the + /// session; rows (a missed response deadline) are excluded from + /// dismiss and dismiss-all and leave only through their own acknowledge action. + /// + public static class NotificationRowTypes + { + public const string Dismissable = "dismissable"; + public const string Acknowledge = "acknowledge"; + } + + public static class NotificationTargetKinds + { + public const string WorkOrder = "workOrder"; + public const string Queue = "queue"; + } + + /// Named work-order queues a grouped item opens. + public static class NotificationQueues + { + public const string Unassigned = "unassigned"; + } + + /// Work-order detail tabs a single-work-order item can open on. + public static class NotificationWorkOrderTabs + { + public const string Info = "info"; + public const string Comments = "comments"; + public const string Extras = "extras"; + } + + public sealed class NotificationTargetDto + { + public string Kind { get; init; } = NotificationTargetKinds.WorkOrder; + public int? WorkOrderId { get; init; } + public string? Tab { get; init; } + public string? Queue { get; init; } + } + + public sealed class NotificationWorkOrderRefDto + { + public int Id { get; init; } + public string? Number { get; init; } + } + + public sealed class NotificationItemDto + { + /// Stable across reads while the condition holds; the client dismisses by it. + public string Id { get; init; } = ""; + public string Reason { get; init; } = ""; + public string Severity { get; init; } = NotificationSeverities.Low; + public string RowType { get; init; } = NotificationRowTypes.Dismissable; + public string Title { get; init; } = ""; + /// Work orders this item represents. + public int Count { get; init; } + /// When the condition started; drives recency ordering. + public DateTime TriggeredAt { get; init; } + public NotificationTargetDto Target { get; init; } = new(); + public IReadOnlyList WorkOrders { get; init; } = Array.Empty(); + } + + public sealed class NotificationSectionDto + { + public string Reason { get; init; } = ""; + public string Label { get; init; } = ""; + /// Highest item severity in the section. + public string Severity { get; init; } = NotificationSeverities.Low; + /// Work orders the whole section represents, including items beyond the returned slice. + public int Count { get; init; } + public IReadOnlyList Items { get; init; } = Array.Empty(); + } + + public sealed class NotificationFeedDto + { + public DateTime GeneratedAt { get; init; } + public IReadOnlyList Sections { get; init; } = Array.Empty(); + } +} diff --git a/SeaHaven.Services/Helpers/NotificationFeedOrdering.cs b/SeaHaven.Services/Helpers/NotificationFeedOrdering.cs new file mode 100644 index 0000000..85a285c --- /dev/null +++ b/SeaHaven.Services/Helpers/NotificationFeedOrdering.cs @@ -0,0 +1,57 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class NotificationFeedOrdering + { + /// + /// Drops empty sections, orders items by severity then recency, and orders sections by + /// their highest severity with the fixed reason order breaking ties. + /// + public static IReadOnlyList Order( + IEnumerable<(string Reason, int Count, IReadOnlyList Items)> sections) + { + return sections + .Where(section => section.Items.Count > 0) + .Select(section => + { + var items = section.Items + .OrderByDescending(item => NotificationSeverities.Rank(item.Severity)) + .ThenByDescending(item => item.TriggeredAt) + .ThenBy(item => item.Id, StringComparer.Ordinal) + .ToList(); + return new NotificationSectionDto + { + Reason = section.Reason, + Label = NotificationReasons.Labels[section.Reason], + Severity = items[0].Severity, + Count = section.Count, + Items = items + }; + }) + .OrderByDescending(section => NotificationSeverities.Rank(section.Severity)) + .ThenBy(section => ReasonIndex(section.Reason)) + .ToList(); + } + + private static int ReasonIndex(string reason) + { + for (var index = 0; index < NotificationReasons.Order.Count; index++) + { + if (NotificationReasons.Order[index] == reason) + return index; + } + return NotificationReasons.Order.Count; + } + } + + public static class NotificationBusinessTime + { + /// Business-calendar date of in the dashboard's time zone. + public static DateOnly Today(DateTime utcNow) + { + var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId); + return DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(utcNow, timezone)); + } + } +} diff --git a/SeaHaven.Services/Helpers/VendorConflictPairs.cs b/SeaHaven.Services/Helpers/VendorConflictPairs.cs new file mode 100644 index 0000000..0adaf43 --- /dev/null +++ b/SeaHaven.Services/Helpers/VendorConflictPairs.cs @@ -0,0 +1,34 @@ +using SeaHaven.DataServices.Models; + +namespace SeaHaven.Services.Helpers +{ + public static class VendorConflictPairs + { + // Group by vendor so cross-vendor intervals are never compared, then sweep the + // start-sorted windows within a vendor. Every overlapping pair is still emitted; + // output is O(n log n + p) where p is the number of emitted pairs, so the worst + // case stays quadratic only when a single vendor has many mutually-overlapping + // windows, which is inherent to enumerating all such pairs. + public static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> Build( + IReadOnlyList dispatches) + { + foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId)) + { + var sorted = group.OrderBy(dispatch => dispatch.Start).ToList(); + for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++) + { + var left = sorted[leftIndex]; + for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++) + { + var right = sorted[rightIndex]; + // Sorted by start: once a window starts at or after left's end, none that follow can overlap it. + if (right.Start >= left.End) + break; + if (left.WorkOrderId != right.WorkOrderId) + yield return (left, right); + } + } + } + } + } +} diff --git a/SeaHaven.Services/Implementation/NotificationFeedService.cs b/SeaHaven.Services/Implementation/NotificationFeedService.cs new file mode 100644 index 0000000..84932e4 --- /dev/null +++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs @@ -0,0 +1,180 @@ +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 + { + /// Most items returned per per-work-order section; section counts stay exact. + 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 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 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)> UnassignedAsync( + int? accountId, DateTime now, CancellationToken cancellationToken) + { + var summary = await _data.GetUnassignedSummaryAsync(accountId, cancellationToken); + if (summary.Count == 0) + return (NotificationReasons.Unassigned, 0, Array.Empty()); + + 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)> 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)> 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)> 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(); + + var items = pairs.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)}"; + } +} diff --git a/SeaHaven.Services/Implementation/VendorOperationsService.cs b/SeaHaven.Services/Implementation/VendorOperationsService.cs index 92d5779..11efd04 100644 --- a/SeaHaven.Services/Implementation/VendorOperationsService.cs +++ b/SeaHaven.Services/Implementation/VendorOperationsService.cs @@ -3,6 +3,7 @@ using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -433,32 +434,9 @@ namespace SeaHaven.Services.Implementation .ToList(); } - // Group by vendor so cross-vendor intervals are never compared, then sweep the - // start-sorted windows within a vendor. Every overlapping pair is still emitted; - // output is O(n log n + p) where p is the number of emitted pairs, so the worst - // case stays quadratic only when a single vendor has many mutually-overlapping - // windows, which is inherent to enumerating all such pairs. private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs( IReadOnlyList dispatches) - { - foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId)) - { - var sorted = group.OrderBy(dispatch => dispatch.Start).ToList(); - for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++) - { - var left = sorted[leftIndex]; - for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++) - { - var right = sorted[rightIndex]; - // Sorted by start: once a window starts at or after left's end, none that follow can overlap it. - if (right.Start >= left.End) - break; - if (left.WorkOrderId != right.WorkOrderId) - yield return (left, right); - } - } - } - } + => VendorConflictPairs.Build(dispatches); private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd) => leftStart < rightEnd && rightStart < leftEnd; diff --git a/SeaHaven.Services/Interfaces/INotificationFeedService.cs b/SeaHaven.Services/Interfaces/INotificationFeedService.cs new file mode 100644 index 0000000..46fe195 --- /dev/null +++ b/SeaHaven.Services/Interfaces/INotificationFeedService.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + /// + /// Notification Center feed for the signed-in user: live conditions derived from current + /// work-order state at read time, account-scoped from claims and grouped by reason. + /// + public interface INotificationFeedService + { + Task GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken); + } +} From aad3facaf1faf1bb5975bff8e5ba9558168bcd59 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 18 Sep 2026 12:59:56 -0300 Subject: [PATCH 2/3] 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. --- .../NotificationFeedServiceTests.cs | 58 +++++++++++++++++++ .../NotificationFeedDataService.cs | 7 ++- .../VendorOperationsDataService.cs | 5 +- .../Implementation/NotificationFeedService.cs | 9 ++- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs index cb702a4..9e8910e 100644 --- a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs @@ -301,6 +301,31 @@ public class NotificationFeedServiceTests .Should().BeEquivalentTo(reminderIds); } + [Fact] + public async Task NoVendor_TreatsSoftDeletedDispatchesAsNoVendor() + { + using var context = NewContext(); + var deletedDirect = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DEL-DIRECT"); + var deletedLinked = Open(1, "dispatcher-1", Now.AddHours(22), "NV-DEL-LINKED"); + context.workOrders.AddRange(deletedDirect, deletedLinked); + await context.SaveChangesAsync(); + var vendor = new Vendor { CompanyName = "Removed", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + var direct = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true }; + var linked = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true }; + context.Dispatches.AddRange(direct, linked); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linked.Id, WorkOrderId = deletedLinked.Id }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + Section(feed, NotificationReasons.NoVendor).Items + .Select(item => item.WorkOrders.Single().Number) + .Should().BeEquivalentTo("NV-DEL-DIRECT", "NV-DEL-LINKED"); + } + [Fact] public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency() { @@ -429,6 +454,39 @@ public class NotificationFeedServiceTests unrelated.Sections.Should().BeEmpty(); } + [Fact] + public async Task VendorConflict_CapsByRecencyKeepingNewerOverlaps() + { + using var context = NewContext(); + // One vendor whose older cluster produces more overlapping pairs than the cap, plus a + // single newer overlap. Build sweeps a vendor's windows in start order, so the newer pair + // is emitted last and is exactly what an unsorted Take(limit) would drop. + var oldStart = Now.AddDays(3); + var oldOrders = new List(); + for (var index = 0; index < 11; index++) + oldOrders.Add(Open(1, "dispatcher-1", oldStart, $"OLD-{index}")); + var newerLeft = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-A"); + var newerRight = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-B"); + context.workOrders.AddRange(oldOrders); + context.workOrders.AddRange(newerLeft, newerRight); + await context.SaveChangesAsync(); + + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + foreach (var workOrder in oldOrders.Append(newerLeft).Append(newerRight)) + context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledDate }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.VendorConflict); + section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit); + section.Items.Should().Contain(item => + item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-A") + && item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-B")); + } + [Fact] public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder() { diff --git a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs index 7741550..3bef86c 100644 --- a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs +++ b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs @@ -54,15 +54,18 @@ namespace SeaHaven.DataServices.Implementation public Task 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. + // "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); diff --git a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs index 0468f43..4fabfba 100644 --- a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs @@ -23,12 +23,15 @@ namespace SeaHaven.DataServices.Implementation { var directlyAssignedIds = await _context.Dispatches .AsNoTracking() - .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) + .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") + && (d.IsDeleted == null || d.IsDeleted == false) + && d.WorkOrderId.HasValue) .Select(d => d.WorkOrderId!.Value) .ToListAsync(cancellationToken); var multiplyAssignedIds = await _context.DispatchWorkOrders .AsNoTracking() .Where(link => link.Dispatch != null + && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) .Select(link => link.WorkOrderId) .ToListAsync(cancellationToken); diff --git a/SeaHaven.Services/Implementation/NotificationFeedService.cs b/SeaHaven.Services/Implementation/NotificationFeedService.cs index 84932e4..2051e77 100644 --- a/SeaHaven.Services/Implementation/NotificationFeedService.cs +++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs @@ -149,7 +149,14 @@ namespace SeaHaven.Services.Implementation .Distinct() .Count(); - var items = pairs.Take(SectionItemLimit).Select(pair => new NotificationItemDto + // 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, From c9fa4a49af20a836aae3fd45c3ce0e10f9c2f77c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 18 Sep 2026 13:09:46 -0300 Subject: [PATCH 3/3] style(notifications): fix object initializer indentation in NotificationFeedService --- .../Implementation/NotificationFeedService.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/SeaHaven.Services/Implementation/NotificationFeedService.cs b/SeaHaven.Services/Implementation/NotificationFeedService.cs index 2051e77..e66c82d 100644 --- a/SeaHaven.Services/Implementation/NotificationFeedService.cs +++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs @@ -157,20 +157,20 @@ namespace SeaHaven.Services.Implementation .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[] + { + 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(); + }).ToList(); return (NotificationReasons.VendorConflict, workOrderCount, items); }