mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
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).
220 lines
11 KiB
C#
220 lines
11 KiB
C#
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);
|
|
}
|
|
}
|