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