mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
feat(notifications): feed assignments, comments, mentions and uplift decisions to the user they concern
Adds the personal producers behind GET /api/notifications without changing its shape: new assignments (SH-288), unanswered comments on work the user takes part in (SH-289), @mentions, and decisions on uplifts the user requested (SH-215).
This commit is contained in:
parent
da0b29f769
commit
e668e13912
7 changed files with 901 additions and 4 deletions
452
Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs
Normal file
452
Api.SeaHavenIndustries.Tests/NotificationActivityFeedTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Items addressed to the signed-in user: new assignments (SH-288), comments (SH-289),
|
||||
/// mentions, and decisions on their uplift requests (SH-215).
|
||||
/// </summary>
|
||||
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<ApplicationDbContext>()
|
||||
.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<NotificationItemDto> Items(NotificationFeedDto feed, string reason)
|
||||
=> feed.Sections.SingleOrDefault(section => section.Reason == reason)?.Items
|
||||
?? Array.Empty<NotificationItemDto>();
|
||||
|
||||
// ---- 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<OperationCanceledException>();
|
||||
await FluentActions.Awaiting(() => data.GetUnansweredCommentsAsync(scope, Now, 10, cancelled.Token))
|
||||
.Should().ThrowAsync<OperationCanceledException>();
|
||||
await FluentActions.Awaiting(() => data.GetUpliftDecisionsAsync(scope, Now, 10, cancelled.Token))
|
||||
.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<WorkOrder> OpenWorkOrders(NotificationFeedScope scope)
|
||||
public async Task<IReadOnlyList<NotificationAssignmentCandidate>> 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<IReadOnlyList<NotificationCommentCandidate>> 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<IReadOnlyList<NotificationUpliftDecisionCandidate>> 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<WorkOrder> 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<WorkOrder> OpenWorkOrders(NotificationFeedScope scope)
|
||||
{
|
||||
var workOrders = ScopedWorkOrders(scope.AccountId);
|
||||
if (scope.DispatcherId != null)
|
||||
workOrders = workOrders.Where(w => w.AssignTo == scope.DispatcherId);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,5 +25,28 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
/// </summary>
|
||||
Task<NotificationCandidatePage> GetNoVendorAsync(
|
||||
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Open work orders assigned to the user whose latest assignment happened at or after
|
||||
/// <paramref name="since"/> and was made by someone else, read from the latest AssignTo audit
|
||||
/// entry (board create writes one too). Returns at most <paramref name="limit"/>, newest first.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotificationAssignmentCandidate>> GetRecentAssignmentsAsync(
|
||||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Comments posted at or after <paramref name="since"/> 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 <paramref name="limit"/>, newest first.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotificationCommentCandidate>> GetUnansweredCommentsAsync(
|
||||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Approve, reject and revoke decisions made at or after <paramref name="since"/> by someone else on
|
||||
/// uplifts the user requested. Returns at most <paramref name="limit"/>, newest first.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
|
||||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,3 +25,42 @@ public sealed class NotificationCandidatePage
|
|||
public int Total { get; init; }
|
||||
public IReadOnlyList<NotificationWorkOrderCandidate> Items { get; init; } = Array.Empty<NotificationWorkOrderCandidate>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-derived scope for items addressed to one person: <see cref="UserId"/> is the signed-in
|
||||
/// user from claims and <see cref="AccountId"/> null is org-wide.
|
||||
/// </summary>
|
||||
public sealed record NotificationPersonalScope(int? AccountId, string UserId);
|
||||
|
||||
/// <summary>An open work order assigned to the user, with when and by whom it was last assigned.</summary>
|
||||
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; }
|
||||
/// <summary>Free-text author for comments with no user (email sync, vendor portal).</summary>
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public static class NotificationQueues
|
||||
{
|
||||
public const string Unassigned = "unassigned";
|
||||
/// <summary>The signed-in user's own open work orders.</summary>
|
||||
public const string AssignedToMe = "assigned-to-me";
|
||||
}
|
||||
|
||||
/// <summary>Work-order detail tabs a single-work-order item can open on.</summary>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
220
SeaHaven.Services/Helpers/NotificationActivityItems.cs
Normal file
220
SeaHaven.Services/Helpers/NotificationActivityItems.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static class NotificationActivityItems
|
||||
{
|
||||
/// <summary>Assignments by one person this close together count as one action.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>One High item per comment that mentions the user, opening the work order's Comments tab.</summary>
|
||||
public static IReadOnlyList<NotificationItemDto> Mentions(
|
||||
IEnumerable<NotificationCommentCandidate> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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. <c>TriggeredAt</c> is the
|
||||
/// earliest comment in the group, so the Comments tab can highlight the group from it.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<NotificationItemDto> Comments(
|
||||
IEnumerable<NotificationCommentCandidate> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Low items for new assignments. Assignments made by the same person within
|
||||
/// <see cref="AssignmentActionGap"/> of each other are one action: a single work order opens
|
||||
/// directly, several become one item that opens the user's own queue.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<NotificationItemDto> Assignments(IEnumerable<NotificationAssignmentCandidate> assignments)
|
||||
{
|
||||
var items = new List<NotificationItemDto>();
|
||||
foreach (var byActor in assignments.GroupBy(a => a.AssignedBy ?? "", StringComparer.Ordinal))
|
||||
{
|
||||
var action = new List<NotificationAssignmentCandidate>();
|
||||
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<NotificationAssignmentCandidate>();
|
||||
}
|
||||
action.Add(assignment);
|
||||
}
|
||||
if (action.Count > 0)
|
||||
items.Add(AssignmentItem(action));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<NotificationItemDto> UpliftDecisions(IEnumerable<NotificationUpliftDecisionCandidate> 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)}";
|
||||
|
||||
/// <summary>Comment or reason text on one line, mention tokens shown as "@Name", cut to 80 characters.</summary>
|
||||
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<NotificationAssignmentCandidate> 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<NotificationWorkOrderRefDto> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,12 @@ namespace SeaHaven.Services.Implementation
|
|||
/// <summary>Most items returned per per-work-order section; section counts stay exact.</summary>
|
||||
public const int SectionItemLimit = 50;
|
||||
|
||||
/// <summary>How long an assignment, comment, mention or uplift decision stays in the feed.</summary>
|
||||
public static readonly TimeSpan RecentActivityWindow = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Most source rows read per personal event type before grouping.</summary>
|
||||
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<IReadOnlyList<(string, int, IReadOnlyList<NotificationItemDto>)>> 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<NotificationItemDto>) BoundedSection(
|
||||
string reason, IReadOnlyList<NotificationItemDto> 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<NotificationItemDto>)> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue