mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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:
commit
e1ce3e439b
14 changed files with 1248 additions and 30 deletions
588
Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs
Normal file
588
Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,12 +23,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
var directlyAssignedIds = await _context.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "")
|
||||
&& (d.IsDeleted == null || d.IsDeleted == false)
|
||||
&& d.WorkOrderId.HasValue)
|
||||
.Select(d => d.WorkOrderId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
var multiplyAssignedIds = await _context.DispatchWorkOrders
|
||||
.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||
.Select(link => link.WorkOrderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
|
@ -56,14 +59,23 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.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 ?? "")
|
||||
&& (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 +83,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 +107,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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, 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<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken);
|
||||
Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken);
|
||||
|
|
|
|||
27
SeaHaven.DataServices/Models/NotificationFeedReadModels.cs
Normal file
27
SeaHaven.DataServices/Models/NotificationFeedReadModels.cs
Normal 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>();
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
131
SeaHaven.Services/DTOs/NotificationFeedDTOs.cs
Normal file
131
SeaHaven.Services/DTOs/NotificationFeedDTOs.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
57
SeaHaven.Services/Helpers/NotificationFeedOrdering.cs
Normal file
57
SeaHaven.Services/Helpers/NotificationFeedOrdering.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
34
SeaHaven.Services/Helpers/VendorConflictPairs.cs
Normal file
34
SeaHaven.Services/Helpers/VendorConflictPairs.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
SeaHaven.Services/Implementation/NotificationFeedService.cs
Normal file
187
SeaHaven.Services/Implementation/NotificationFeedService.cs
Normal 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)}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
=> VendorConflictPairs.Build(dispatches);
|
||||
|
||||
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
|
||||
=> leftStart < rightEnd && rightStart < leftEnd;
|
||||
|
|
|
|||
14
SeaHaven.Services/Interfaces/INotificationFeedService.cs
Normal file
14
SeaHaven.Services/Interfaces/INotificationFeedService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue