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