Merge pull request #149 from Sea-Haven-Industries/feat/ab/sh-292-notification-center

SH-292: Notification Center feed endpoint
This commit is contained in:
Alexandre Brandizzi 2026-09-18 22:54:54 +00:00 • committed by GitHub
commit e1ce3e439b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1248 additions and 30 deletions

View file

@ -0,0 +1,588 @@
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<ApplicationDbContext>()
.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<NoVendorNotification>().Select(item => item.Id);
Section(feed, NotificationReasons.NoVendor).Items.Select(item => item.Id)
.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()
{
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<NotificationItemDto>)[]
{
(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<NotificationItemDto>()),
(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 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<WorkOrder>();
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()
{
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<WorkOrderBoardValidationException>()).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<WorkOrderBoardValidationException>()).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<OperationCanceledException>();
}
[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<ObjectResult>().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<OkObjectResult>().Which.Value.Should().BeOfType<NotificationFeedDto>().Subject;
feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned);
}
[Fact]
public void Controller_RequiresAnAuthenticatedCaller()
{
typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
}
}

View file

@ -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<IActionResult> 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
});
}
}
}
}

View file

@ -0,0 +1,115 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Implementation
{
public class NotificationFeedDataService : INotificationFeedDataService
{
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
private readonly ApplicationDbContext _context;
public NotificationFeedDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<UnassignedWorkOrdersSummary> GetUnassignedSummaryAsync(
int? accountId, CancellationToken cancellationToken)
{
var summary = await OpenWorkOrders(new NotificationFeedScope(accountId, null))
.Where(w => w.AssignTo == null || w.AssignTo == "")
.GroupBy(_ => 1)
.Select(group => new UnassignedWorkOrdersSummary
{
Count = group.Count(),
LatestCreatedAt = group.Max(w => w.CreatedDate)
})
.FirstOrDefaultAsync(cancellationToken);
return summary ?? new UnassignedWorkOrdersSummary();
}
public Task<NotificationCandidatePage> GetAvetaMissingAsync(
NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken)
{
// Same rule as the dashboard's Aveta Pending tile so the two counts agree.
var windowStart = today.ToDateTime(TimeOnly.MinValue);
var windowEnd = today.AddDays(2).ToDateTime(TimeOnly.MinValue);
var candidates = OpenWorkOrders(scope)
.Where(w => w.AvetaRequired
&& w.ScheduledDate >= windowStart
&& w.ScheduledDate < windowEnd
&& !w.workOrderAttachments!.Any(attachment =>
(attachment.IsDeleted == null || attachment.IsDeleted == false)
&& attachment.Category == WorkOrderMediaCategory.Aveta));
return PageAsync(candidates, limit, cancellationToken);
}
public Task<NotificationCandidatePage> GetNoVendorAsync(
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken)
{
// "Has a vendor" matches the vendor reminders: any 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);
}
private IQueryable<WorkOrder> OpenWorkOrders(NotificationFeedScope scope)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (scope.AccountId is int accountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId);
if (scope.DispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == scope.DispatcherId);
return workOrders.Where(w => w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& (w.LifecycleStatus != null
|| ((w.LegacyStatus ?? w.Status) != "Done"
&& (w.LegacyStatus ?? w.Status) != "Completed"
&& (w.LegacyStatus ?? w.Status) != "Complete"
&& (w.LegacyStatus ?? w.Status) != "Closed"
&& (w.LegacyStatus ?? w.Status) != "Canceled"
&& (w.LegacyStatus ?? w.Status) != "Cancelled")));
}
private static async Task<NotificationCandidatePage> PageAsync(
IQueryable<WorkOrder> candidates, int limit, CancellationToken cancellationToken)
{
var total = await candidates.CountAsync(cancellationToken);
if (total == 0)
return new NotificationCandidatePage();
var items = await candidates
.OrderByDescending(w => w.ScheduledDate)
.ThenByDescending(w => w.Id)
.Take(limit)
.Select(w => new NotificationWorkOrderCandidate
{
Id = w.Id,
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
ScheduledDate = w.ScheduledDate!.Value
})
.ToListAsync(cancellationToken);
return new NotificationCandidatePage { Total = total, Items = items };
}
}
}

View file

@ -23,12 +23,15 @@ namespace SeaHaven.DataServices.Implementation
{ {
var directlyAssignedIds = await _context.Dispatches var directlyAssignedIds = await _context.Dispatches
.AsNoTracking() .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) .Select(d => d.WorkOrderId!.Value)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var multiplyAssignedIds = await _context.DispatchWorkOrders var multiplyAssignedIds = await _context.DispatchWorkOrders
.AsNoTracking() .AsNoTracking()
.Where(link => link.Dispatch != null .Where(link => link.Dispatch != null
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
.Select(link => link.WorkOrderId) .Select(link => link.WorkOrderId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@ -56,14 +59,23 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken) public Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken)
=> GetActiveScheduledDispatchesAsync(null, cancellationToken);
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(
int? accountId, CancellationToken cancellationToken)
{ {
var direct = _context.Dispatches.AsNoTracking() var directDispatches = _context.Dispatches.AsNoTracking()
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false) && (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
&& dispatch.WorkOrderId.HasValue && dispatch.WorkOrderId.HasValue
&& (dispatch.ScheduledDate.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 .Select(dispatch => new ScheduledDispatchReadModel
{ {
Id = dispatch.Id, Id = dispatch.Id,
@ -71,18 +83,23 @@ namespace SeaHaven.DataServices.Implementation
VendorName = dispatch.Vendor!.CompanyName, VendorName = dispatch.Vendor!.CompanyName,
WorkOrderId = dispatch.WorkOrderId!.Value, WorkOrderId = dispatch.WorkOrderId!.Value,
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
AssignTo = dispatch.WorkOrder.AssignTo,
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
End = dispatch.WorkOrder.ScheduledEnd End = dispatch.WorkOrder.ScheduledEnd
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
}); });
var linked = _context.DispatchWorkOrders.AsNoTracking() var linkedDispatches = _context.DispatchWorkOrders.AsNoTracking()
.Where(link => link.Dispatch != null .Where(link => link.Dispatch != null
&& link.WorkOrder != null && link.WorkOrder != null
&& link.Dispatch.WorkOrderId != link.WorkOrderId && link.Dispatch.WorkOrderId != link.WorkOrderId
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) && (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 .Select(link => new ScheduledDispatchReadModel
{ {
Id = link.DispatchId, Id = link.DispatchId,
@ -90,6 +107,7 @@ namespace SeaHaven.DataServices.Implementation
VendorName = link.Dispatch.Vendor!.CompanyName, VendorName = link.Dispatch.Vendor!.CompanyName,
WorkOrderId = link.WorkOrderId, WorkOrderId = link.WorkOrderId,
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
AssignTo = link.WorkOrder.AssignTo,
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
End = link.WorkOrder.ScheduledEnd End = link.WorkOrder.ScheduledEnd
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)

View file

@ -0,0 +1,29 @@
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// 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).
/// </summary>
public interface INotificationFeedDataService
{
/// <summary>Open work orders with no dispatcher assigned in <paramref name="accountId"/> (null is org-wide).</summary>
Task<UnassignedWorkOrdersSummary> GetUnassignedSummaryAsync(int? accountId, CancellationToken cancellationToken);
/// <summary>
/// Open, Aveta-required work orders scheduled on <paramref name="today"/> or the next day with no
/// Aveta document attached. Items are the <paramref name="limit"/> latest scheduled.
/// </summary>
Task<NotificationCandidatePage> GetAvetaMissingAsync(
NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken);
/// <summary>
/// Open work orders scheduled at or before <paramref name="cutoff"/> with no active vendor dispatch,
/// direct or linked. Items are the <paramref name="limit"/> latest scheduled.
/// </summary>
Task<NotificationCandidatePage> GetNoVendorAsync(
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken);
}
}

View file

@ -8,6 +8,8 @@ namespace SeaHaven.DataServices.Interfaces
Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken); Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken); Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken);
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken); Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken);
/// <summary>Same windows limited to work orders of <paramref name="accountId"/>; null is org-wide.</summary>
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(int? accountId, CancellationToken cancellationToken);
Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken); Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken); Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken);
Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken); Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken);

View file

@ -0,0 +1,27 @@
namespace SeaHaven.DataServices.Models;
/// <summary>
/// Server-derived feed scope. <see cref="AccountId"/> null is org-wide;
/// <see cref="DispatcherId"/> null means every dispatcher's work orders.
/// </summary>
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; }
}
/// <summary>A bounded slice of candidates plus the unbounded total they were taken from.</summary>
public sealed class NotificationCandidatePage
{
public int Total { get; init; }
public IReadOnlyList<NotificationWorkOrderCandidate> Items { get; init; } = Array.Empty<NotificationWorkOrderCandidate>();
}

View file

@ -7,6 +7,7 @@ public sealed class ScheduledDispatchReadModel
public string? VendorName { get; set; } public string? VendorName { get; set; }
public int WorkOrderId { get; set; } public int WorkOrderId { get; set; }
public string? WorkOrderNumber { get; set; } public string? WorkOrderNumber { get; set; }
public string? AssignTo { get; set; }
public DateTime Start { get; set; } public DateTime Start { get; set; }
public DateTime End { get; set; } public DateTime End { get; set; }
} }

View file

@ -0,0 +1,131 @@
namespace SeaHaven.Services.DTOs
{
/// <summary>
/// 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.
/// </summary>
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<string> Order = new[]
{
SeverityCritical, Sla, Mentions, Unassigned, DueSoon, NoVendor, AvetaMissing, Activity, VendorConflict
};
public static readonly IReadOnlyDictionary<string, string> Labels = new Dictionary<string, string>
{
[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
};
}
/// <summary>
/// How a row leaves the feed. <see cref="Dismissable"/> rows are hidden client-side for the
/// session; <see cref="Acknowledge"/> rows (a missed response deadline) are excluded from
/// dismiss and dismiss-all and leave only through their own acknowledge action.
/// </summary>
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";
}
/// <summary>Named work-order queues a grouped item opens.</summary>
public static class NotificationQueues
{
public const string Unassigned = "unassigned";
}
/// <summary>Work-order detail tabs a single-work-order item can open on.</summary>
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
{
/// <summary>Stable across reads while the condition holds; the client dismisses by it.</summary>
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; } = "";
/// <summary>Work orders this item represents.</summary>
public int Count { get; init; }
/// <summary>When the condition started; drives recency ordering.</summary>
public DateTime TriggeredAt { get; init; }
public NotificationTargetDto Target { get; init; } = new();
public IReadOnlyList<NotificationWorkOrderRefDto> WorkOrders { get; init; } = Array.Empty<NotificationWorkOrderRefDto>();
}
public sealed class NotificationSectionDto
{
public string Reason { get; init; } = "";
public string Label { get; init; } = "";
/// <summary>Highest item severity in the section.</summary>
public string Severity { get; init; } = NotificationSeverities.Low;
/// <summary>Work orders the whole section represents, including items beyond the returned slice.</summary>
public int Count { get; init; }
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
}
public sealed class NotificationFeedDto
{
public DateTime GeneratedAt { get; init; }
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
}
}

View file

@ -0,0 +1,57 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Helpers
{
public static class NotificationFeedOrdering
{
/// <summary>
/// Drops empty sections, orders items by severity then recency, and orders sections by
/// their highest severity with the fixed reason order breaking ties.
/// </summary>
public static IReadOnlyList<NotificationSectionDto> Order(
IEnumerable<(string Reason, int Count, IReadOnlyList<NotificationItemDto> 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
{
/// <summary>Business-calendar date of <paramref name="utcNow"/> in the dashboard's time zone.</summary>
public static DateOnly Today(DateTime utcNow)
{
var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId);
return DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(utcNow, timezone));
}
}
}

View file

@ -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<ScheduledDispatchReadModel> 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);
}
}
}
}
}
}

View file

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

View file

@ -3,6 +3,7 @@ using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation namespace SeaHaven.Services.Implementation
@ -433,32 +434,9 @@ namespace SeaHaven.Services.Implementation
.ToList(); .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( private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs(
IReadOnlyList<ScheduledDispatchReadModel> dispatches) IReadOnlyList<ScheduledDispatchReadModel> dispatches)
{ => VendorConflictPairs.Build(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);
}
}
}
}
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd) private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
=> leftStart < rightEnd && rightStart < leftEnd; => leftStart < rightEnd && rightStart < leftEnd;

View file

@ -0,0 +1,14 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// 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.
/// </summary>
public interface INotificationFeedService
{
Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken);
}
}