diff --git a/Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs b/Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs
new file mode 100644
index 0000000..e81c349
--- /dev/null
+++ b/Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs
@@ -0,0 +1,452 @@
+using System.Security.Claims;
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using SeaHaven.DataServices.Implementation;
+using SeaHaven.DataServices.Models;
+using SeaHaven.Services.DTOs;
+using SeaHaven.Services.Helpers;
+using SeaHaven.Services.Implementation;
+using Xunit;
+
+namespace Api.SeaHavenIndustries.Tests;
+
+///
+/// Items addressed to the signed-in user: new assignments (SH-288), comments (SH-289),
+/// mentions, and decisions on their uplift requests (SH-215).
+///
+public class NotificationActivityFeedTests
+{
+ private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
+ private const string Me = "dispatcher-me";
+ private const string Other = "dispatcher-other";
+ private const string Admin = "admin-1";
+
+ private sealed class FixedTimeProvider : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => new(Now);
+ }
+
+ private static ApplicationDbContext NewContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ var context = new ApplicationDbContext(options);
+ context.Users.AddRange(
+ new ApplicationUser { Id = Me, FirstName = "Maya", LastName = "Reed" },
+ new ApplicationUser { Id = Other, FirstName = "Omar", LastName = "Diaz" },
+ new ApplicationUser { Id = Admin, FirstName = "Ada", LastName = "Stone" });
+ context.SaveChanges();
+ return context;
+ }
+
+ private static NotificationFeedService NewService(ApplicationDbContext context)
+ => new(
+ new NotificationFeedDataService(context),
+ new VendorOperationsDataService(context, new DispatchDataService(context)),
+ new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)),
+ new FixedTimeProvider());
+
+ private static ClaimsPrincipal User(int accountId, string role, string userId)
+ => new(new ClaimsIdentity(new[]
+ {
+ new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
+ new Claim(ClaimTypes.NameIdentifier, userId),
+ new Claim(ClaimTypes.Role, role)
+ }, "test"));
+
+ private static ClaimsPrincipal Dispatcher(int accountId = 1, string userId = Me) => User(accountId, "Dispatcher", userId);
+
+ private static WorkOrder AddWorkOrder(ApplicationDbContext context, int accountId, string? assignTo, string number,
+ LifecycleStatus status = LifecycleStatus.Pending)
+ {
+ var workOrder = new WorkOrder
+ {
+ AccountId = accountId,
+ AssignTo = assignTo,
+ InternalWONumber = number,
+ LifecycleStatus = status,
+ CreatedDate = Now.AddDays(-30)
+ };
+ context.workOrders.Add(workOrder);
+ context.SaveChanges();
+ return workOrder;
+ }
+
+ private static void Assigned(ApplicationDbContext context, WorkOrder workOrder, string? by, DateTime at, string field = "AssignTo")
+ {
+ context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
+ {
+ WorkOrderId = workOrder.Id,
+ UserId = by,
+ FieldName = field,
+ OldValue = "",
+ NewValue = workOrder.AssignTo,
+ Action = "AssignmentChanged",
+ CreatedAt = at
+ });
+ context.SaveChanges();
+ }
+
+ private static Comments Comment(ApplicationDbContext context, WorkOrder workOrder, string? by, string text, DateTime at,
+ string? commenter = null)
+ {
+ var comment = new Comments
+ {
+ WorkerOrderId = workOrder.Id,
+ UserId = by,
+ Commenter = commenter,
+ Commenttext = text,
+ CommentType = "General",
+ RecordType = "WorkOrder",
+ CreatedDate = at
+ };
+ context.Comments.Add(comment);
+ context.SaveChanges();
+ return comment;
+ }
+
+ private static DispatchUpliftRequest Uplift(ApplicationDbContext context, WorkOrder workOrder, string requestedBy,
+ string status, decimal amount, string? decidedBy, DateTime? decidedAt, string? note = null)
+ {
+ var dispatch = new Dispatch { WorkOrderId = workOrder.Id, Status = "Sent" };
+ context.Dispatches.Add(dispatch);
+ context.SaveChanges();
+ var uplift = new DispatchUpliftRequest
+ {
+ DispatchId = dispatch.Id,
+ RequestedNTE = amount,
+ Status = status,
+ createdby = requestedBy,
+ DecidedAt = decidedAt,
+ DecidedByUserId = decidedBy,
+ DecisionNote = note,
+ CreatedDate = Now.AddDays(-2)
+ };
+ context.DispatchUpliftRequests.Add(uplift);
+ context.SaveChanges();
+ return uplift;
+ }
+
+ private static IReadOnlyList Items(NotificationFeedDto feed, string reason)
+ => feed.Sections.SingleOrDefault(section => section.Reason == reason)?.Items
+ ?? Array.Empty();
+
+ // ---- SH-288 new assignments ----
+
+ [Fact]
+ public async Task Assignment_BySomeoneElse_IsOneLowActivityItemThatOpensTheWorkOrder()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Me, "WO-100");
+ Assigned(context, workOrder, Other, Now.AddHours(-3));
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
+ item.Id.Should().Be($"new-assigned-{workOrder.Id}");
+ item.Severity.Should().Be(NotificationSeverities.Low);
+ item.RowType.Should().Be(NotificationRowTypes.Dismissable);
+ item.Title.Should().Be("WO #WO-100 was assigned to you");
+ item.Count.Should().Be(1);
+ item.TriggeredAt.Should().Be(Now.AddHours(-3));
+ item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc);
+ item.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder);
+ item.Target.WorkOrderId.Should().Be(workOrder.Id);
+ item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Info);
+ feed.Sections.Single(s => s.Reason == NotificationReasons.Activity).Label.Should().Be("Recent activity");
+ }
+
+ [Fact]
+ public async Task Assignments_MadeInOneAction_BecomeOneGroupedItemThatOpensTheUsersQueue()
+ {
+ using var context = NewContext();
+ var first = AddWorkOrder(context, 1, Me, "WO-1");
+ var second = AddWorkOrder(context, 1, Me, "WO-2");
+ var third = AddWorkOrder(context, 1, Me, "WO-3");
+ var later = AddWorkOrder(context, 1, Me, "WO-4");
+ Assigned(context, first, Other, Now.AddHours(-5));
+ Assigned(context, second, Other, Now.AddHours(-5).AddSeconds(20));
+ Assigned(context, third, Other, Now.AddHours(-5).AddSeconds(50));
+ // Same person, an hour later: a separate action.
+ Assigned(context, later, Other, Now.AddHours(-4));
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ var items = Items(feed, NotificationReasons.Activity);
+ items.Should().HaveCount(2);
+ var grouped = items.Single(i => i.Count == 3);
+ grouped.Id.Should().Be($"new-assigned-group-{first.Id}");
+ grouped.Title.Should().Be("3 new work orders assigned to you");
+ grouped.Severity.Should().Be(NotificationSeverities.Low);
+ grouped.Target.Kind.Should().Be(NotificationTargetKinds.Queue);
+ grouped.Target.Queue.Should().Be(NotificationQueues.AssignedToMe);
+ grouped.WorkOrders.Select(w => w.Id).Should().BeEquivalentTo(new[] { first.Id, second.Id, third.Id });
+ items.Single(i => i.Count == 1).Target.WorkOrderId.Should().Be(later.Id);
+ }
+
+ [Fact]
+ public async Task Assignment_IsNotShownWhenSelfMadeStaleClosedOrReassignedAway()
+ {
+ using var context = NewContext();
+ Assigned(context, AddWorkOrder(context, 1, Me, "SELF"), Me, Now.AddHours(-1));
+ Assigned(context, AddWorkOrder(context, 1, Me, "STALE"), Other, Now.AddDays(-8));
+ Assigned(context, AddWorkOrder(context, 1, Me, "DONE", LifecycleStatus.Completed), Other, Now.AddHours(-1));
+ var movedAway = AddWorkOrder(context, 1, Me, "MOVED");
+ Assigned(context, movedAway, Other, Now.AddHours(-2));
+ movedAway.AssignTo = Other;
+ Assigned(context, movedAway, Admin, Now.AddHours(-1));
+ // Reassigned to me earlier, then someone re-saved it to me again recently: the latest entry counts.
+ var reassigned = AddWorkOrder(context, 1, Me, "LATEST");
+ Assigned(context, reassigned, Other, Now.AddDays(-9));
+ Assigned(context, reassigned, Admin, Now.AddMinutes(-10));
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ Items(feed, NotificationReasons.Activity).Should().ContainSingle()
+ .Which.Target.WorkOrderId.Should().Be(reassigned.Id);
+ }
+
+ [Fact]
+ public async Task Assignment_CountsLegacyAuditEntriesButNotUnauditedAssignees()
+ {
+ using var context = NewContext();
+ var legacy = AddWorkOrder(context, 1, Me, "LEGACY");
+ Assigned(context, legacy, Other, Now.AddDays(-1), field: "AssignedTo");
+ // Assigned at some point with no audit trail: there is no assignment event to report.
+ var unaudited = AddWorkOrder(context, 1, Me, "UNAUDITED");
+ unaudited.CreatedDate = Now.AddHours(-2);
+ context.SaveChanges();
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ Items(feed, NotificationReasons.Activity).Should().ContainSingle()
+ .Which.Target.WorkOrderId.Should().Be(legacy.Id);
+ }
+
+ // ---- SH-289 comments and mentions ----
+
+ [Fact]
+ public async Task Comments_BySomeoneElseOnMyWorkOrder_GroupIntoOneMediumItemOnTheCommentsTab()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Me, "WO-7");
+ var first = Comment(context, workOrder, Other, "Vendor is on site", Now.AddHours(-3));
+ var second = Comment(context, workOrder, null, "Reply from the email thread", Now.AddHours(-2), commenter: "Site Lead");
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
+ item.Id.Should().Be($"comments-{workOrder.Id}-{second.Id}");
+ item.Title.Should().Be("2 new comments on WO #WO-7");
+ item.Severity.Should().Be(NotificationSeverities.Medium);
+ item.Count.Should().Be(1);
+ // The group starts at its earliest comment; the Comments tab highlights from there.
+ item.TriggeredAt.Should().Be(first.CreatedDate!.Value);
+ item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc);
+ item.Target.WorkOrderId.Should().Be(workOrder.Id);
+ item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments);
+ }
+
+ [Fact]
+ public async Task Comment_Single_ShowsTheCommenterAndAOneLinePreview()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Me, "WO-8");
+ var text = "Please ask @[" + Admin + ":Ada Stone] about the gate code\nbefore the vendor arrives, because the "
+ + "site closes early on Fridays and security will not let anyone in after four";
+ Comment(context, workOrder, Other, text, Now.AddHours(-1));
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ var title = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject.Title;
+ title.Should().StartWith("Omar Diaz commented on WO #WO-8: “Please ask @Ada Stone about the gate code before");
+ title.Should().EndWith("…”");
+ NotificationActivityItems.Preview(text).Should().HaveLength(80);
+ }
+
+ [Fact]
+ public async Task Comments_NeverNotifyTheirAuthorAndClearOnceTheUserReplies()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Me, "WO-9");
+ Comment(context, workOrder, Me, "My own note", Now.AddHours(-5));
+ Comment(context, workOrder, Other, "Answered already", Now.AddHours(-4));
+ Comment(context, workOrder, Me, "Thanks", Now.AddHours(-3));
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+ Items(feed, NotificationReasons.Activity).Should().BeEmpty();
+
+ var unanswered = Comment(context, workOrder, Other, "One more thing", Now.AddHours(-1));
+ feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+ var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
+ item.Title.Should().Be("Omar Diaz commented on WO #WO-9: “One more thing”");
+ item.TriggeredAt.Should().Be(unanswered.CreatedDate!.Value);
+ }
+
+ [Fact]
+ public async Task Comments_ReachPriorCommentersButNotBystanders()
+ {
+ using var context = NewContext();
+ // Someone else's work order that I commented on earlier this month.
+ var joined = AddWorkOrder(context, 1, Other, "JOINED");
+ Comment(context, joined, Me, "Adding context", Now.AddDays(-20));
+ Comment(context, joined, Other, "Follow-up", Now.AddHours(-2));
+ var bystander = AddWorkOrder(context, 1, Other, "NOT-MINE");
+ Comment(context, bystander, Admin, "Internal note", Now.AddHours(-2));
+ var stale = AddWorkOrder(context, 1, Me, "OLD");
+ Comment(context, stale, Other, "Last week", Now.AddDays(-8));
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ Items(feed, NotificationReasons.Activity).Should().ContainSingle()
+ .Which.Target.WorkOrderId.Should().Be(joined.Id);
+ // The owner of NOT-MINE is notified of the admin's note, and not of their own follow-up.
+ var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None);
+ Items(ownerFeed, NotificationReasons.Activity).Should().ContainSingle()
+ .Which.Target.WorkOrderId.Should().Be(bystander.Id);
+ }
+
+ [Fact]
+ public async Task Mention_IsAHighMentionsItemAndIsNotAlsoCountedAsAComment()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Me, "WO-11");
+ Comment(context, workOrder, Other, "Plain update", Now.AddHours(-3));
+ var mention = Comment(context, workOrder, Other, "@[" + Me + ":Maya Reed] can you confirm?", Now.AddHours(-2));
+ // A mention reaches the user even on a work order they are not otherwise part of.
+ var elsewhere = AddWorkOrder(context, 1, Other, "WO-12");
+ Comment(context, elsewhere, Admin, "Looping in @[" + Me + ":Maya Reed]", Now.AddHours(-1));
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ var mentions = Items(feed, NotificationReasons.Mentions);
+ mentions.Should().HaveCount(2);
+ var onMine = mentions.Single(i => i.Target.WorkOrderId == workOrder.Id);
+ onMine.Id.Should().Be($"mention-{mention.Id}");
+ onMine.Title.Should().Be("Omar Diaz mentioned you on WO #WO-11");
+ onMine.Severity.Should().Be(NotificationSeverities.High);
+ onMine.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments);
+ onMine.TriggeredAt.Should().Be(mention.CreatedDate!.Value);
+ mentions.Single(i => i.Target.WorkOrderId == elsewhere.Id).Title.Should().Be("Ada Stone mentioned you on WO #WO-12");
+
+ var activity = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
+ activity.Title.Should().Be("Omar Diaz commented on WO #WO-11: “Plain update”");
+ feed.Sections.Select(s => s.Reason).Should().ContainInOrder(NotificationReasons.Mentions, NotificationReasons.Activity);
+ }
+
+ [Fact]
+ public void MentionsUser_MatchesOnlyTheUsersOwnToken()
+ {
+ NotificationActivityItems.MentionsUser("hi @[" + Me + ":Maya Reed]", Me).Should().BeTrue();
+ NotificationActivityItems.MentionsUser("hi @[" + Me + "x:Someone Else]", Me).Should().BeFalse();
+ NotificationActivityItems.MentionsUser("hi @Maya Reed", Me).Should().BeFalse();
+ NotificationActivityItems.MentionsUser(null, Me).Should().BeFalse();
+ }
+
+ // ---- SH-215 uplift decisions ----
+
+ [Fact]
+ public async Task UpliftDecisions_OnMyRequests_ShowAmountAndReasonAndOpenTheUplifts()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Other, "WO-20");
+ var approved = Uplift(context, workOrder, Me, "Approved", 1250m, Admin, Now.AddHours(-3));
+ var rejected = Uplift(context, workOrder, Me, "Rejected", 400m, Admin, Now.AddHours(-2), "Quote does not match the scope agreed");
+ var revoked = Uplift(context, workOrder, Me, "Revoked", 90.5m, Admin, Now.AddHours(-1), "Duplicate of an earlier uplift");
+ var legacy = Uplift(context, workOrder, Me, "Denied", 75m, Admin, Now.AddHours(-4), "Old wording");
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ var items = Items(feed, NotificationReasons.Activity);
+ items.Should().HaveCount(4);
+ var approvedItem = items.Single(i => i.Id == $"uplift-approved-{approved.Id}");
+ approvedItem.Title.Should().Be("Your $1,250.00 uplift on WO #WO-20 was approved");
+ approvedItem.Severity.Should().Be(NotificationSeverities.Medium);
+ approvedItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Uplifts);
+ approvedItem.Target.WorkOrderId.Should().Be(workOrder.Id);
+ var rejectedItem = items.Single(i => i.Id == $"uplift-rejected-{rejected.Id}");
+ rejectedItem.Title.Should().Be("Your $400.00 uplift on WO #WO-20 was rejected: “Quote does not match the scope agreed”");
+ rejectedItem.Severity.Should().Be(NotificationSeverities.High);
+ var revokedItem = items.Single(i => i.Id == $"uplift-revoked-{revoked.Id}");
+ revokedItem.Title.Should().Be("Your $90.50 uplift on WO #WO-20 was revoked: “Duplicate of an earlier uplift”");
+ revokedItem.Severity.Should().Be(NotificationSeverities.High);
+ revokedItem.TriggeredAt.Should().Be(Now.AddHours(-1));
+ items.Single(i => i.Id == $"uplift-rejected-{legacy.Id}").Severity.Should().Be(NotificationSeverities.High);
+ // The requester need not be the dispatcher who owns the work order.
+ var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None);
+ Items(ownerFeed, NotificationReasons.Activity).Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task UpliftDecisions_SkipPendingAutoApprovedSelfRevokedAndStaleRequests()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Me, "WO-21");
+ Uplift(context, workOrder, Me, "Pending", 100m, null, null);
+ Uplift(context, workOrder, Me, "NoApprovalRequired", 100m, null, null);
+ Uplift(context, workOrder, Me, "Revoked", 100m, Me, Now.AddHours(-1));
+ Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddDays(-8));
+ var deleted = Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddHours(-1));
+ deleted.IsDeleted = true;
+ context.SaveChanges();
+
+ var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
+
+ Items(feed, NotificationReasons.Activity).Should().BeEmpty();
+ }
+
+ // ---- scope ----
+
+ [Fact]
+ public async Task PersonalItems_NeverCrossAccounts()
+ {
+ using var context = NewContext();
+ var foreign = AddWorkOrder(context, 2, Me, "FOREIGN");
+ Assigned(context, foreign, Other, Now.AddHours(-1));
+ Comment(context, foreign, Other, "@[" + Me + ":Maya Reed] hello", Now.AddHours(-1));
+ Uplift(context, foreign, Me, "Approved", 10m, Admin, Now.AddHours(-1));
+
+ var sameUserOtherAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 1), CancellationToken.None);
+ sameUserOtherAccount.Sections.Should().BeEmpty();
+
+ var inAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 2), CancellationToken.None);
+ Items(inAccount, NotificationReasons.Mentions).Should().ContainSingle();
+ Items(inAccount, NotificationReasons.Activity).Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task PersonalItems_ReachEveryFeedRoleByUserId()
+ {
+ using var context = NewContext();
+ var workOrder = AddWorkOrder(context, 1, Other, "WO-30");
+ Comment(context, workOrder, Other, "@[" + Admin + ":Ada Stone] please approve", Now.AddHours(-1));
+
+ var adminFeed = await NewService(context).GetFeedAsync(User(1, "Admin", Admin), CancellationToken.None);
+ Items(adminFeed, NotificationReasons.Mentions).Should().ContainSingle()
+ .Which.Title.Should().Be("Omar Diaz mentioned you on WO #WO-30");
+
+ var managerFeed = await NewService(context).GetFeedAsync(User(1, "Manager", "manager-1"), CancellationToken.None);
+ Items(managerFeed, NotificationReasons.Mentions).Should().BeEmpty();
+ Items(managerFeed, NotificationReasons.Activity).Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task PersonalQueries_ForwardCancellation()
+ {
+ using var context = NewContext();
+ var data = new NotificationFeedDataService(context);
+ var scope = new NotificationPersonalScope(1, Me);
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+
+ await FluentActions.Awaiting(() => data.GetRecentAssignmentsAsync(scope, Now, 10, cancelled.Token))
+ .Should().ThrowAsync();
+ await FluentActions.Awaiting(() => data.GetUnansweredCommentsAsync(scope, Now, 10, cancelled.Token))
+ .Should().ThrowAsync();
+ await FluentActions.Awaiting(() => data.GetUpliftDecisionsAsync(scope, Now, 10, cancelled.Token))
+ .Should().ThrowAsync();
+ }
+}
diff --git a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs
index 7741550..eba9cb0 100644
--- a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs
+++ b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs
@@ -11,6 +11,12 @@ namespace SeaHaven.DataServices.Implementation
{
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
+ // Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
+ private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
+
+ private const string AssignToAuditField = "AssignTo";
+ private const string LegacyAssignedToAuditField = "AssignedTo";
+
private readonly ApplicationDbContext _context;
public NotificationFeedDataService(ApplicationDbContext context)
@@ -68,11 +74,116 @@ namespace SeaHaven.DataServices.Implementation
return PageAsync(candidates, limit, cancellationToken);
}
- private IQueryable OpenWorkOrders(NotificationFeedScope scope)
+ public async Task> GetRecentAssignmentsAsync(
+ NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
+ {
+ // Board create and board edits log "AssignTo" with user ids; the legacy assignment endpoint
+ // logs "AssignedTo" with display names. Only the time and the actor are read, so both count.
+ var assignmentLogs = _context.WorkOrderAuditLogs.AsNoTracking()
+ .Where(log => log.FieldName == AssignToAuditField || log.FieldName == LegacyAssignedToAuditField);
+
+ // The latest assignment entry of each work order the user holds now.
+ var candidates =
+ from log in assignmentLogs
+ join w in OpenWorkOrders(new NotificationFeedScope(scope.AccountId, scope.UserId))
+ on log.WorkOrderId equals w.Id
+ where log.CreatedAt >= since
+ && (log.UserId == null || log.UserId != scope.UserId)
+ && !assignmentLogs.Any(later => later.WorkOrderId == log.WorkOrderId
+ && (later.CreatedAt > log.CreatedAt || (later.CreatedAt == log.CreatedAt && later.Id > log.Id)))
+ orderby log.CreatedAt descending, w.Id descending
+ select new NotificationAssignmentCandidate
+ {
+ WorkOrderId = w.Id,
+ Number = w.InternalWONumber ?? w.WorkerOrderNumber,
+ AssignedAt = log.CreatedAt,
+ AssignedBy = log.UserId
+ };
+
+ return await candidates.Take(limit).ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetUnansweredCommentsAsync(
+ NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
+ {
+ var userId = scope.UserId;
+ // SH-217 stores a mention as the token "@[userId:Display Name]" inside the comment text.
+ var mentionToken = "@[" + userId + ":";
+ var ownComments = _context.Comments.AsNoTracking()
+ .Where(own => own.UserId == userId && own.IsDeleted != true);
+
+ var candidates =
+ from c in _context.Comments.AsNoTracking()
+ join w in ScopedWorkOrders(scope.AccountId) on c.WorkerOrderId equals w.Id
+ where c.IsDeleted != true
+ && c.CreatedDate != null
+ && c.CreatedDate >= since
+ && (c.UserId == null || c.UserId != userId)
+ && (w.AssignTo == userId
+ || (c.Commenttext != null && c.Commenttext.Contains(mentionToken))
+ || ownComments.Any(own => own.WorkerOrderId == w.Id))
+ && !ownComments.Any(own => own.WorkerOrderId == w.Id && own.CreatedDate >= c.CreatedDate)
+ orderby c.CreatedDate descending, c.Id descending
+ select new NotificationCommentCandidate
+ {
+ CommentId = c.Id,
+ WorkOrderId = w.Id,
+ WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber,
+ AuthorFirstName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : null,
+ AuthorLastName = c.ApplicationUser != null ? c.ApplicationUser.LastName : null,
+ Commenter = c.Commenter,
+ Text = c.Commenttext,
+ CreatedAt = c.CreatedDate!.Value
+ };
+
+ return await candidates.Take(limit).ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetUpliftDecisionsAsync(
+ NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
+ {
+ var userId = scope.UserId;
+ // Same work-order resolution as the uplift reads: the dispatch's own work order,
+ // otherwise the first work order linked through DispatchWorkOrders.
+ var candidates =
+ from u in _context.DispatchUpliftRequests.AsNoTracking()
+ join d in _context.Dispatches.AsNoTracking() on u.DispatchId equals d.Id
+ let workOrderId = d.WorkOrderId
+ ?? d.DispatchWorkOrders!.Select(link => (int?)link.WorkOrderId).FirstOrDefault()
+ join w in ScopedWorkOrders(scope.AccountId) on workOrderId equals (int?)w.Id
+ where u.IsDeleted != true
+ && d.IsDeleted != true
+ && u.createdby == userId
+ && u.DecidedAt != null
+ && u.DecidedAt >= since
+ && (u.DecidedByUserId == null || u.DecidedByUserId != userId)
+ && DecisionStatuses.Contains(u.Status)
+ orderby u.DecidedAt descending, u.Id descending
+ select new NotificationUpliftDecisionCandidate
+ {
+ UpliftId = u.Id,
+ WorkOrderId = w.Id,
+ WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber,
+ Status = u.Status,
+ Amount = u.RequestedNTE,
+ DecisionNote = u.DecisionNote,
+ DecidedAt = u.DecidedAt!.Value
+ };
+
+ return await candidates.Take(limit).ToListAsync(cancellationToken);
+ }
+
+ private IQueryable ScopedWorkOrders(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
- if (scope.AccountId is int accountId)
- workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId);
+ return accountId is int id
+ ? WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, id)
+ : workOrders;
+ }
+
+ private IQueryable OpenWorkOrders(NotificationFeedScope scope)
+ {
+ var workOrders = ScopedWorkOrders(scope.AccountId);
if (scope.DispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == scope.DispatcherId);
diff --git a/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs
index d96e59d..1976764 100644
--- a/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs
+++ b/SeaHaven.DataServices/Interfaces/INotificationFeedDataService.cs
@@ -25,5 +25,28 @@ namespace SeaHaven.DataServices.Interfaces
///
Task GetNoVendorAsync(
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken);
+
+ ///
+ /// Open work orders assigned to the user whose latest assignment happened at or after
+ /// and was made by someone else, read from the latest AssignTo audit
+ /// entry (board create writes one too). Returns at most , newest first.
+ ///
+ Task> GetRecentAssignmentsAsync(
+ NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
+
+ ///
+ /// Comments posted at or after by someone other than the user, on work orders
+ /// the user is assigned to, has commented on, or that mention the user, and that the user has not
+ /// commented after. Returns at most , newest first.
+ ///
+ Task> GetUnansweredCommentsAsync(
+ NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
+
+ ///
+ /// Approve, reject and revoke decisions made at or after by someone else on
+ /// uplifts the user requested. Returns at most , newest first.
+ ///
+ Task> GetUpliftDecisionsAsync(
+ NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
}
}
diff --git a/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs
index 9d6f0ae..d404be2 100644
--- a/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs
+++ b/SeaHaven.DataServices/Models/NotificationFeedReadModels.cs
@@ -25,3 +25,42 @@ public sealed class NotificationCandidatePage
public int Total { get; init; }
public IReadOnlyList Items { get; init; } = Array.Empty();
}
+
+///
+/// Server-derived scope for items addressed to one person: is the signed-in
+/// user from claims and null is org-wide.
+///
+public sealed record NotificationPersonalScope(int? AccountId, string UserId);
+
+/// An open work order assigned to the user, with when and by whom it was last assigned.
+public sealed class NotificationAssignmentCandidate
+{
+ public int WorkOrderId { get; init; }
+ public string? Number { get; init; }
+ public DateTime AssignedAt { get; init; }
+ public string? AssignedBy { get; init; }
+}
+
+public sealed class NotificationCommentCandidate
+{
+ public int CommentId { get; init; }
+ public int WorkOrderId { get; init; }
+ public string? WorkOrderNumber { get; init; }
+ public string? AuthorFirstName { get; init; }
+ public string? AuthorLastName { get; init; }
+ /// Free-text author for comments with no user (email sync, vendor portal).
+ public string? Commenter { get; init; }
+ public string? Text { get; init; }
+ public DateTime CreatedAt { get; init; }
+}
+
+public sealed class NotificationUpliftDecisionCandidate
+{
+ public int UpliftId { get; init; }
+ public int WorkOrderId { get; init; }
+ public string? WorkOrderNumber { get; init; }
+ public string Status { get; init; } = "";
+ public decimal Amount { get; init; }
+ public string? DecisionNote { get; init; }
+ public DateTime DecidedAt { get; init; }
+}
diff --git a/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs
index d6aacc7..a2ab7e9 100644
--- a/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs
+++ b/SeaHaven.Services/DTOs/NotificationFeedDTOs.cs
@@ -72,6 +72,8 @@ namespace SeaHaven.Services.DTOs
public static class NotificationQueues
{
public const string Unassigned = "unassigned";
+ /// The signed-in user's own open work orders.
+ public const string AssignedToMe = "assigned-to-me";
}
/// Work-order detail tabs a single-work-order item can open on.
@@ -80,6 +82,7 @@ namespace SeaHaven.Services.DTOs
public const string Info = "info";
public const string Comments = "comments";
public const string Extras = "extras";
+ public const string Uplifts = "uplifts";
}
public sealed class NotificationTargetDto
diff --git a/SeaHaven.Services/Helpers/NotificationActivityItems.cs b/SeaHaven.Services/Helpers/NotificationActivityItems.cs
new file mode 100644
index 0000000..c7d0493
--- /dev/null
+++ b/SeaHaven.Services/Helpers/NotificationActivityItems.cs
@@ -0,0 +1,220 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using SeaHaven.DataServices.Models;
+using SeaHaven.Services.DTOs;
+
+namespace SeaHaven.Services.Helpers
+{
+ ///
+ /// Builds the feed items addressed to one person: new assignments, comments, mentions and
+ /// uplift decisions. Copy and severities follow the prototype's Notification Center
+ /// (new-assigned Low, new-comment Medium, mention High).
+ ///
+ public static class NotificationActivityItems
+ {
+ /// Assignments by one person this close together count as one action.
+ public static readonly TimeSpan AssignmentActionGap = TimeSpan.FromMinutes(2);
+
+ private const int PreviewLength = 80;
+
+ // SH-217 mention token: "@[userId:Display Name]".
+ private static readonly Regex MentionToken = new(@"@\[([^:\]]+):([^\]]+)\]", RegexOptions.Compiled);
+ private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
+ private static readonly CultureInfo Money = CultureInfo.GetCultureInfo("en-US");
+
+ public static bool MentionsUser(string? text, string userId)
+ => !string.IsNullOrEmpty(text)
+ && MentionToken.Matches(text).Any(match => match.Groups[1].Value == userId);
+
+ /// One High item per comment that mentions the user, opening the work order's Comments tab.
+ public static IReadOnlyList Mentions(
+ IEnumerable comments, string userId)
+ {
+ return comments
+ .Where(comment => MentionsUser(comment.Text, userId))
+ .Select(comment => new NotificationItemDto
+ {
+ Id = $"mention-{comment.CommentId}",
+ Reason = NotificationReasons.Mentions,
+ Severity = NotificationSeverities.High,
+ Title = $"{AuthorName(comment)} mentioned you on {WorkOrderLabel(comment.WorkOrderId, comment.WorkOrderNumber)}",
+ Count = 1,
+ TriggeredAt = Utc(comment.CreatedAt),
+ Target = WorkOrderTarget(comment.WorkOrderId, NotificationWorkOrderTabs.Comments),
+ WorkOrders = WorkOrderRefs(comment.WorkOrderId, comment.WorkOrderNumber)
+ })
+ .ToList();
+ }
+
+ ///
+ /// One Medium item per work order for the comments that do not mention the user; a comment
+ /// that mentions them is a Mention item instead, never both. TriggeredAt is the
+ /// earliest comment in the group, so the Comments tab can highlight the group from it.
+ ///
+ public static IReadOnlyList Comments(
+ IEnumerable comments, string userId)
+ {
+ return comments
+ .Where(comment => !MentionsUser(comment.Text, userId))
+ .GroupBy(comment => comment.WorkOrderId)
+ .Select(group =>
+ {
+ var ordered = group.OrderBy(c => c.CreatedAt).ThenBy(c => c.CommentId).ToList();
+ var first = ordered[0];
+ var latest = ordered[^1];
+ var label = WorkOrderLabel(first.WorkOrderId, first.WorkOrderNumber);
+ var preview = Preview(first.Text);
+ var title = ordered.Count > 1
+ ? $"{ordered.Count} new comments on {label}"
+ : preview.Length == 0
+ ? $"{AuthorName(first)} commented on {label}"
+ : $"{AuthorName(first)} commented on {label}: “{preview}”";
+ return new NotificationItemDto
+ {
+ // The latest comment is part of the id so a new comment shows again after a dismiss.
+ Id = $"comments-{first.WorkOrderId}-{latest.CommentId}",
+ Reason = NotificationReasons.Activity,
+ Severity = NotificationSeverities.Medium,
+ Title = title,
+ Count = 1,
+ TriggeredAt = Utc(first.CreatedAt),
+ Target = WorkOrderTarget(first.WorkOrderId, NotificationWorkOrderTabs.Comments),
+ WorkOrders = WorkOrderRefs(first.WorkOrderId, first.WorkOrderNumber)
+ };
+ })
+ .ToList();
+ }
+
+ ///
+ /// Low items for new assignments. Assignments made by the same person within
+ /// of each other are one action: a single work order opens
+ /// directly, several become one item that opens the user's own queue.
+ ///
+ public static IReadOnlyList Assignments(IEnumerable assignments)
+ {
+ var items = new List();
+ foreach (var byActor in assignments.GroupBy(a => a.AssignedBy ?? "", StringComparer.Ordinal))
+ {
+ var action = new List();
+ foreach (var assignment in byActor.OrderBy(a => a.AssignedAt).ThenBy(a => a.WorkOrderId))
+ {
+ if (action.Count > 0 && assignment.AssignedAt - action[^1].AssignedAt > AssignmentActionGap)
+ {
+ items.Add(AssignmentItem(action));
+ action = new List();
+ }
+ action.Add(assignment);
+ }
+ if (action.Count > 0)
+ items.Add(AssignmentItem(action));
+ }
+ return items;
+ }
+
+ ///
+ /// One item per decision on an uplift the user requested: Medium when approved, High when
+ /// rejected or revoked because the requester may need to act. Opens the work order's uplifts.
+ ///
+ public static IReadOnlyList UpliftDecisions(IEnumerable decisions)
+ {
+ return decisions.Select(decision =>
+ {
+ var outcome = decision.Status switch
+ {
+ "Approved" => "approved",
+ "Revoked" => "revoked",
+ _ => "rejected"
+ };
+ var reason = outcome == "approved" ? "" : Preview(decision.DecisionNote);
+ var title = $"Your {decision.Amount.ToString("C2", Money)} uplift on "
+ + $"{WorkOrderLabel(decision.WorkOrderId, decision.WorkOrderNumber)} was {outcome}";
+ return new NotificationItemDto
+ {
+ Id = $"uplift-{outcome}-{decision.UpliftId}",
+ Reason = NotificationReasons.Activity,
+ Severity = outcome == "approved" ? NotificationSeverities.Medium : NotificationSeverities.High,
+ Title = reason.Length == 0 ? title : $"{title}: “{reason}”",
+ Count = 1,
+ TriggeredAt = Utc(decision.DecidedAt),
+ Target = WorkOrderTarget(decision.WorkOrderId, NotificationWorkOrderTabs.Uplifts),
+ WorkOrders = WorkOrderRefs(decision.WorkOrderId, decision.WorkOrderNumber)
+ };
+ }).ToList();
+ }
+
+ public static string WorkOrderLabel(int id, string? number)
+ => $"WO #{(string.IsNullOrWhiteSpace(number) ? id.ToString(CultureInfo.InvariantCulture) : number)}";
+
+ /// Comment or reason text on one line, mention tokens shown as "@Name", cut to 80 characters.
+ public static string Preview(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return "";
+ var display = MentionToken.Replace(text, match => "@" + match.Groups[2].Value.Trim());
+ var singleLine = Whitespace.Replace(display, " ").Trim();
+ return singleLine.Length <= PreviewLength
+ ? singleLine
+ : singleLine[..(PreviewLength - 1)].TrimEnd() + "…";
+ }
+
+ private static NotificationItemDto AssignmentItem(IReadOnlyList action)
+ {
+ var first = action[0];
+ if (action.Count == 1)
+ {
+ return new NotificationItemDto
+ {
+ Id = $"new-assigned-{first.WorkOrderId}",
+ Reason = NotificationReasons.Activity,
+ Severity = NotificationSeverities.Low,
+ Title = $"{WorkOrderLabel(first.WorkOrderId, first.Number)} was assigned to you",
+ Count = 1,
+ TriggeredAt = Utc(first.AssignedAt),
+ Target = WorkOrderTarget(first.WorkOrderId, NotificationWorkOrderTabs.Info),
+ WorkOrders = WorkOrderRefs(first.WorkOrderId, first.Number)
+ };
+ }
+
+ return new NotificationItemDto
+ {
+ // Keyed on the action's first work order so the id holds while the group is unchanged.
+ Id = $"new-assigned-group-{first.WorkOrderId}",
+ Reason = NotificationReasons.Activity,
+ Severity = NotificationSeverities.Low,
+ Title = $"{action.Count} new work orders assigned to you",
+ Count = action.Count,
+ TriggeredAt = Utc(first.AssignedAt),
+ Target = new NotificationTargetDto
+ {
+ Kind = NotificationTargetKinds.Queue,
+ Queue = NotificationQueues.AssignedToMe
+ },
+ WorkOrders = action
+ .Select(a => new NotificationWorkOrderRefDto { Id = a.WorkOrderId, Number = a.Number })
+ .ToList()
+ };
+ }
+
+ private static string AuthorName(NotificationCommentCandidate comment)
+ {
+ var name = $"{comment.AuthorFirstName ?? ""} {comment.AuthorLastName ?? ""}".Trim();
+ if (name.Length > 0)
+ return name;
+ return string.IsNullOrWhiteSpace(comment.Commenter) ? "Someone" : comment.Commenter.Trim();
+ }
+
+ private static NotificationTargetDto WorkOrderTarget(int workOrderId, string tab) => new()
+ {
+ Kind = NotificationTargetKinds.WorkOrder,
+ WorkOrderId = workOrderId,
+ Tab = tab
+ };
+
+ private static IReadOnlyList WorkOrderRefs(int id, string? number)
+ => new[] { new NotificationWorkOrderRefDto { Id = id, Number = number } };
+
+ // Stored times are UTC; marking them so keeps the "Z" on the wire and lets the client
+ // compare them with comment times exactly.
+ private static DateTime Utc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
+ }
+}
diff --git a/SeaHaven.Services/Implementation/NotificationFeedService.cs b/SeaHaven.Services/Implementation/NotificationFeedService.cs
index 84932e4..695d40f 100644
--- a/SeaHaven.Services/Implementation/NotificationFeedService.cs
+++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs
@@ -13,6 +13,12 @@ namespace SeaHaven.Services.Implementation
/// Most items returned per per-work-order section; section counts stay exact.
public const int SectionItemLimit = 50;
+ /// How long an assignment, comment, mention or uplift decision stays in the feed.
+ public static readonly TimeSpan RecentActivityWindow = TimeSpan.FromDays(7);
+
+ /// Most source rows read per personal event type before grouping.
+ public const int PersonalCandidateLimit = 500;
+
private static readonly TimeSpan NoVendorLeadTime = TimeSpan.FromHours(48);
private readonly INotificationFeedDataService _data;
@@ -46,6 +52,10 @@ namespace SeaHaven.Services.Implementation
sections.Add(await AvetaMissingAsync(scope, now, cancellationToken));
sections.Add(await VendorConflictsAsync(scope, cancellationToken));
+ var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (!string.IsNullOrWhiteSpace(userId))
+ sections.AddRange(await PersonalSectionsAsync(new NotificationPersonalScope(accountId, userId), now, cancellationToken));
+
return new NotificationFeedDto
{
GeneratedAt = now,
@@ -71,6 +81,45 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
}
+ // Items addressed to the signed-in user whatever their role: assignments to them, comments on
+ // work they take part in, mentions of them, and decisions on uplifts they requested. The
+ // account scope still applies; the dispatcher scope does not, because a mention or an uplift
+ // request can concern a work order owned by another dispatcher.
+ private async Task)>> PersonalSectionsAsync(
+ NotificationPersonalScope scope, DateTime now, CancellationToken cancellationToken)
+ {
+ var since = now.Subtract(RecentActivityWindow);
+ var comments = await _data.GetUnansweredCommentsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
+ var assignments = await _data.GetRecentAssignmentsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
+ var decisions = await _data.GetUpliftDecisionsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
+
+ var mentions = NotificationActivityItems.Mentions(comments, scope.UserId);
+ var activity = NotificationActivityItems.Comments(comments, scope.UserId)
+ .Concat(NotificationActivityItems.Assignments(assignments))
+ .Concat(NotificationActivityItems.UpliftDecisions(decisions))
+ .ToList();
+
+ return new[]
+ {
+ BoundedSection(NotificationReasons.Mentions, mentions),
+ BoundedSection(NotificationReasons.Activity, activity)
+ };
+ }
+
+ // Counts every work order the section covers; returns the most severe, most recent items.
+ private static (string, int, IReadOnlyList) BoundedSection(
+ string reason, IReadOnlyList items)
+ {
+ var workOrderCount = items.SelectMany(item => item.WorkOrders).Select(w => w.Id).Distinct().Count();
+ var bounded = items
+ .OrderByDescending(item => NotificationSeverities.Rank(item.Severity))
+ .ThenByDescending(item => item.TriggeredAt)
+ .ThenBy(item => item.Id, StringComparer.Ordinal)
+ .Take(SectionItemLimit)
+ .ToList();
+ return (reason, workOrderCount, bounded);
+ }
+
private async Task<(string, int, IReadOnlyList)> UnassignedAsync(
int? accountId, DateTime now, CancellationToken cancellationToken)
{
@@ -175,6 +224,6 @@ namespace SeaHaven.Services.Implementation
};
private static string WorkOrderLabel(int id, string? number)
- => $"WO #{(string.IsNullOrWhiteSpace(number) ? id.ToString() : number)}";
+ => NotificationActivityItems.WorkOrderLabel(id, number);
}
}