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); + } +}