mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
26 commits
1888b66940
...
752f5fdea1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
752f5fdea1 | ||
|
|
ae35f10921 | ||
|
|
637ba3350b | ||
| 24e4f2b7f7 | |||
|
|
019eb86894 | ||
| b7b22a8893 | |||
|
|
10266e6e4b | ||
|
|
e1ce3e439b | ||
|
|
0b3084a274 | ||
|
|
cd4d30af4b | ||
|
|
a9773e67eb | ||
| b270543d0d | |||
|
|
582af8fb2c | ||
|
|
51417a65b5 | ||
|
|
503faa8c25 | ||
|
|
1cd3c802e3 | ||
|
|
0556822d22 | ||
|
|
46eed22707 | ||
|
|
cbecc7b4ea | ||
|
|
9784dcf895 | ||
|
|
e668e13912 | ||
|
|
c9fa4a49af | ||
|
|
aad3facaf1 | ||
|
|
ad68bccb89 | ||
|
|
fa979605b4 | ||
|
|
da0b29f769 |
57 changed files with 3678 additions and 294 deletions
|
|
@ -1,11 +1,11 @@
|
|||
# Sea Haven Industries ÔÇö shoc-backend required configuration
|
||||
# Sea Haven Industries — shoc-backend required configuration
|
||||
#
|
||||
# This file documents the secrets that used to be hardcoded in appsettings*.json
|
||||
# and source files. Copy the values into one of the supported configuration sources;
|
||||
# do NOT commit real values.
|
||||
#
|
||||
# .NET resolves configuration in this order (later wins):
|
||||
# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only)
|
||||
# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only)
|
||||
# 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value"
|
||||
# 3. Environment variables (use "__" as the section separator)
|
||||
#
|
||||
|
|
|
|||
63
.gitattributes
vendored
63
.gitattributes
vendored
|
|
@ -1,63 +1,2 @@
|
|||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
# Normalize line endings on checkout and checkin.
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
|
|
|
|||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!--
|
||||
Title: type(scope): description (SH-123)
|
||||
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
|
||||
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
|
||||
Docs-only and configuration-only chores may omit the key.
|
||||
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
|
||||
Scope: one logical change per PR. If the title needs "and", split it.
|
||||
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
|
||||
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What changed and why, in plain language. Two to four sentences. -->
|
||||
|
||||
## Changes and value
|
||||
|
||||
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
|
||||
|
||||
## Ticket
|
||||
|
||||
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->
|
||||
34
.github/renovate.json
vendored
34
.github/renovate.json
vendored
|
|
@ -1,8 +1,22 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"enabledManagers": ["nuget", "github-actions", "terraform"],
|
||||
"enabledManagers": ["nuget", "github-actions", "terraform", "custom.regex"],
|
||||
"schedule": ["before 6am every weekday"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": [
|
||||
"EF_VERSION in the Elastic Beanstalk packaging script installs dotnet-ef at deploy time and must move with the tool manifest and EF Core packages"
|
||||
],
|
||||
"managerFilePatterns": ["/^scripts/package-elastic-beanstalk\\.sh$/"],
|
||||
"matchStrings": ["EF_VERSION=\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
|
||||
"datasourceTemplate": "nuget",
|
||||
"depNameTemplate": "dotnet-ef",
|
||||
"versioningTemplate": "nuget"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": [
|
||||
|
|
@ -12,8 +26,10 @@
|
|||
"dependencyDashboardApproval": true
|
||||
},
|
||||
{
|
||||
"description": ["Group non-major nuget updates"],
|
||||
"matchManagers": ["nuget"],
|
||||
"description": [
|
||||
"Group non-major nuget updates, including the dotnet-ef pin in the packaging script"
|
||||
],
|
||||
"matchManagers": ["nuget", "custom.regex"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "nuget minor and patch"
|
||||
},
|
||||
|
|
@ -35,6 +51,18 @@
|
|||
"matchUpdateTypes": ["major"],
|
||||
"groupName": "aspnetcore and ef core"
|
||||
},
|
||||
{
|
||||
"description": [
|
||||
"net8.0 target: hold ASP.NET Core, EF Core, and dotnet-ef at 8.x until the TargetFramework moves; the group above takes over then"
|
||||
],
|
||||
"matchPackageNames": [
|
||||
"Microsoft.AspNetCore{/,}**",
|
||||
"Microsoft.EntityFrameworkCore{/,}**",
|
||||
"dotnet-ef"
|
||||
],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": ["Keep AWS SDK majors together"],
|
||||
"matchPackageNames": ["/^AWSSDK\\./"],
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ may add stricter backend rules but may never weaken the workspace baseline.
|
|||
|
||||
## Canonical governance documents (precedence)
|
||||
|
||||
1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical;
|
||||
supersedes the older `BACKEND_ARCHITECTURE.md`, which is retained only as
|
||||
historical reference).
|
||||
1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical).
|
||||
2. `QUALITY_GATES.md` — *how* rules are enforced (commands + CI mapping).
|
||||
3. `REVIEW_AND_PR_FRAMEWORK.md` — *who* reviews, in what order, with what
|
||||
evidence.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
# Architecture and Code Quality (canonical)
|
||||
|
||||
> **Status: canonical.** This document owns the *meaning* of the backend's
|
||||
> architecture and code-quality rules. On any conflict with the older
|
||||
> `BACKEND_ARCHITECTURE.md`, **this document governs**; that file is retained
|
||||
> only as historical reference pending removal.
|
||||
> architecture and code-quality rules. It replaced the earlier
|
||||
> `BACKEND_ARCHITECTURE.md`, which now lives only in git history.
|
||||
>
|
||||
> Companion documents:
|
||||
> - `QUALITY_GATES.md` — *how* each rule is enforced (commands + CI mapping).
|
||||
|
|
|
|||
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>();
|
||||
}
|
||||
}
|
||||
588
Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs
Normal file
588
Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class NotificationFeedServiceTests
|
||||
{
|
||||
// 12:00 in New York, so the business date is 2026-09-18.
|
||||
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime Today = new(2026, 9, 18);
|
||||
|
||||
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;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static NotificationFeedService NewService(ApplicationDbContext context)
|
||||
{
|
||||
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context));
|
||||
return new NotificationFeedService(
|
||||
new NotificationFeedDataService(context),
|
||||
new VendorOperationsDataService(context, new DispatchDataService(context)),
|
||||
resolver,
|
||||
new FixedTimeProvider());
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal User(int accountId, string role, string userId = "user-1")
|
||||
=> new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
}, "test"));
|
||||
|
||||
private static ClaimsPrincipal OrgWideAdmin()
|
||||
=> new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
||||
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
}, "test"));
|
||||
|
||||
private static WorkOrder Open(int accountId, string? assignTo = null, DateTime? scheduled = null, string? number = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = accountId,
|
||||
AssignTo = assignTo,
|
||||
ScheduledDate = scheduled,
|
||||
InternalWONumber = number,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CreatedDate = Now.AddDays(-3)
|
||||
};
|
||||
|
||||
private static WorkOrder AvetaDue(int accountId, string assignTo, DateTime scheduled, string number)
|
||||
{
|
||||
var workOrder = Open(accountId, assignTo, scheduled, number);
|
||||
workOrder.AvetaRequired = true;
|
||||
return workOrder;
|
||||
}
|
||||
|
||||
private static NotificationSectionDto Section(NotificationFeedDto feed, string reason)
|
||||
=> feed.Sections.Single(section => section.Reason == reason);
|
||||
|
||||
private static void AddActiveDispatch(ApplicationDbContext context, WorkOrder workOrder)
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
context.Vendors.Add(vendor);
|
||||
context.SaveChanges();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = workOrder.ScheduledDate
|
||||
});
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unassigned_GroupsEveryOpenUnassignedWorkOrderIntoOneHighItem()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var newest = Open(1);
|
||||
newest.CreatedDate = Now.AddHours(-1);
|
||||
var completed = Open(1);
|
||||
completed.LifecycleStatus = LifecycleStatus.Completed;
|
||||
var canceled = Open(1);
|
||||
canceled.LifecycleStatus = LifecycleStatus.Canceled;
|
||||
var legacyDone = Open(1);
|
||||
legacyDone.LifecycleStatus = null;
|
||||
legacyDone.Status = "Done";
|
||||
var template = Open(1);
|
||||
template.istemplate = true;
|
||||
var deleted = Open(1);
|
||||
deleted.IsDeleted = true;
|
||||
context.workOrders.AddRange(
|
||||
Open(1), Open(1, assignTo: ""), newest, Open(1, assignTo: "dispatcher-1"),
|
||||
completed, canceled, legacyDone, template, deleted);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
|
||||
|
||||
var section = Section(feed, NotificationReasons.Unassigned);
|
||||
section.Label.Should().Be("Unassigned");
|
||||
section.Count.Should().Be(3);
|
||||
var item = section.Items.Should().ContainSingle().Subject;
|
||||
item.Id.Should().Be("unassigned");
|
||||
item.Severity.Should().Be(NotificationSeverities.High);
|
||||
item.RowType.Should().Be(NotificationRowTypes.Dismissable);
|
||||
item.Title.Should().Be("3 work orders are unassigned");
|
||||
item.Count.Should().Be(3);
|
||||
item.TriggeredAt.Should().Be(newest.CreatedDate);
|
||||
item.Target.Kind.Should().Be(NotificationTargetKinds.Queue);
|
||||
item.Target.Queue.Should().Be(NotificationQueues.Unassigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unassigned_CountFollowsAssignmentAndSectionDisappearsAtZero()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var first = Open(1);
|
||||
var second = Open(1);
|
||||
context.workOrders.AddRange(first, second);
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
first.AssignTo = "dispatcher-1";
|
||||
await context.SaveChangesAsync();
|
||||
var afterOne = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None);
|
||||
var item = Section(afterOne, NotificationReasons.Unassigned).Items.Single();
|
||||
item.Title.Should().Be("1 work order is unassigned");
|
||||
item.Id.Should().Be("unassigned", "the id stays stable so a session dismissal survives count changes");
|
||||
|
||||
second.AssignTo = "dispatcher-2";
|
||||
await context.SaveChangesAsync();
|
||||
var afterAll = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None);
|
||||
afterAll.Sections.Should().NotContain(section => section.Reason == NotificationReasons.Unassigned);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin")]
|
||||
[InlineData("Manager")]
|
||||
[InlineData("Scheduler")]
|
||||
public async Task Unassigned_IsShownToRolesThatSeeEveryDispatcher(string role)
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.Add(Open(1));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, role), CancellationToken.None);
|
||||
|
||||
Section(feed, NotificationReasons.Unassigned).Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unassigned_IsNotShownToDispatchers()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.Add(Open(1));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
|
||||
|
||||
feed.Sections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AvetaMissing_OnlyFlagsOpenRequiredWorkOrdersInTheOneDayWindowWithoutTheDocument()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var dueToday = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-TODAY");
|
||||
var dueTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(1).AddHours(9), "A-TOMORROW");
|
||||
var withDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(10), "A-HAS-DOC");
|
||||
var withDeletedDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(11), "A-DELETED-DOC");
|
||||
var dayAfterTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(2).AddHours(9), "A-LATER");
|
||||
var yesterday = AvetaDue(1, "dispatcher-1", Today.AddDays(-1).AddHours(9), "A-PAST");
|
||||
var completed = AvetaDue(1, "dispatcher-1", Today.AddHours(12), "A-DONE");
|
||||
completed.LifecycleStatus = LifecycleStatus.Completed;
|
||||
var notRequired = Open(1, "dispatcher-1", Today.AddHours(13), "A-NOT-REQUIRED");
|
||||
context.workOrders.AddRange(
|
||||
dueToday, dueTomorrow, withDocument, withDeletedDocument, dayAfterTomorrow, yesterday, completed, notRequired);
|
||||
await context.SaveChangesAsync();
|
||||
context.workOrderAttachments.AddRange(
|
||||
new WorkOrderAttachments { WorkorderId = withDocument.Id, Category = WorkOrderMediaCategory.Aveta },
|
||||
new WorkOrderAttachments { WorkorderId = withDeletedDocument.Id, Category = WorkOrderMediaCategory.Aveta, IsDeleted = true },
|
||||
new WorkOrderAttachments { WorkorderId = dueToday.Id, Category = WorkOrderMediaCategory.Extra });
|
||||
await context.SaveChangesAsync();
|
||||
AddActiveDispatch(context, dueToday);
|
||||
AddActiveDispatch(context, dueTomorrow);
|
||||
AddActiveDispatch(context, withDeletedDocument);
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
|
||||
|
||||
var section = Section(feed, NotificationReasons.AvetaMissing);
|
||||
section.Label.Should().Be("Aveta Missing");
|
||||
section.Count.Should().Be(3);
|
||||
section.Items.Select(item => item.WorkOrders.Single().Number)
|
||||
.Should().Equal("A-TOMORROW", "A-TODAY", "A-DELETED-DOC");
|
||||
var tomorrowItem = section.Items[0];
|
||||
tomorrowItem.Id.Should().Be($"aveta-missing-{dueTomorrow.Id}");
|
||||
tomorrowItem.Severity.Should().Be(NotificationSeverities.Medium);
|
||||
tomorrowItem.Title.Should().Be("WO #A-TOMORROW is missing the Aveta document");
|
||||
tomorrowItem.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder);
|
||||
tomorrowItem.Target.WorkOrderId.Should().Be(dueTomorrow.Id);
|
||||
tomorrowItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Extras);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AvetaMissing_ClearsOnceTheDocumentIsAttached()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var workOrder = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-1");
|
||||
context.workOrders.Add(workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
AddActiveDispatch(context, workOrder);
|
||||
var service = NewService(context);
|
||||
var dispatcher = User(1, "Dispatcher", "dispatcher-1");
|
||||
|
||||
(await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections
|
||||
.Should().ContainSingle(section => section.Reason == NotificationReasons.AvetaMissing);
|
||||
|
||||
context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = workOrder.Id, Category = WorkOrderMediaCategory.Aveta });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
(await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoVendor_MatchesTheVendorReminderRuleWithinFortyEightHours()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var noVendor = Open(1, "dispatcher-1", Now.AddHours(24), "NV-1");
|
||||
var withDispatch = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DISPATCHED");
|
||||
var withLinkedDispatch = Open(1, "dispatcher-1", Now.AddHours(22), "NV-LINKED");
|
||||
var withFinishedDispatch = Open(1, "dispatcher-1", Now.AddHours(30), "NV-FINISHED");
|
||||
var tooFar = Open(1, "dispatcher-1", Now.AddHours(72), "NV-LATER");
|
||||
context.workOrders.AddRange(noVendor, withDispatch, withLinkedDispatch, withFinishedDispatch, tooFar);
|
||||
await context.SaveChangesAsync();
|
||||
AddActiveDispatch(context, withDispatch);
|
||||
var vendor = new Vendor { CompanyName = "Linked", IsActive = true };
|
||||
context.Vendors.Add(vendor);
|
||||
await context.SaveChangesAsync();
|
||||
var multi = new Dispatch { VendorId = vendor.Id, WorkOrderId = withDispatch.Id, Status = "Sent" };
|
||||
var finished = new Dispatch { VendorId = vendor.Id, WorkOrderId = withFinishedDispatch.Id, Status = "Completed" };
|
||||
context.Dispatches.AddRange(multi, finished);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = multi.Id, WorkOrderId = withLinkedDispatch.Id });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
|
||||
|
||||
var section = Section(feed, NotificationReasons.NoVendor);
|
||||
section.Label.Should().Be("No Vendor");
|
||||
section.Items.Select(item => item.WorkOrders.Single().Number).Should().Equal("NV-FINISHED", "NV-1");
|
||||
var item = section.Items.Single(row => row.Id == $"no-vendor-{noVendor.Id}");
|
||||
item.Severity.Should().Be(NotificationSeverities.Medium);
|
||||
item.Title.Should().Be("WO #NV-1 starts within 48h with no vendor assigned");
|
||||
item.TriggeredAt.Should().Be(noVendor.ScheduledDate!.Value.AddHours(-48));
|
||||
item.Target.WorkOrderId.Should().Be(noVendor.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoVendor_FlagsTheSameWorkOrdersAsTheVendorOperationsReminders()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var first = Open(1, "dispatcher-1", Now.AddHours(10), "P-1");
|
||||
var second = Open(2, "dispatcher-2", Now.AddHours(40), "P-2");
|
||||
var dispatched = Open(1, "dispatcher-1", Now.AddHours(12), "P-3");
|
||||
context.workOrders.AddRange(first, second, dispatched);
|
||||
await context.SaveChangesAsync();
|
||||
AddActiveDispatch(context, dispatched);
|
||||
var vendorService = new VendorOperationsService(
|
||||
new VendorOperationsDataService(context, new DispatchDataService(context)), new FixedTimeProvider());
|
||||
|
||||
var reminders = await vendorService.GetNotificationsAsync(CancellationToken.None);
|
||||
var feed = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var reminderIds = reminders.Items.OfType<NoVendorNotification>().Select(item => item.Id);
|
||||
Section(feed, NotificationReasons.NoVendor).Items.Select(item => item.Id)
|
||||
.Should().BeEquivalentTo(reminderIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoVendor_TreatsSoftDeletedDispatchesAsNoVendor()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var deletedDirect = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DEL-DIRECT");
|
||||
var deletedLinked = Open(1, "dispatcher-1", Now.AddHours(22), "NV-DEL-LINKED");
|
||||
context.workOrders.AddRange(deletedDirect, deletedLinked);
|
||||
await context.SaveChangesAsync();
|
||||
var vendor = new Vendor { CompanyName = "Removed", IsActive = true };
|
||||
context.Vendors.Add(vendor);
|
||||
await context.SaveChangesAsync();
|
||||
var direct = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true };
|
||||
var linked = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true };
|
||||
context.Dispatches.AddRange(direct, linked);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linked.Id, WorkOrderId = deletedLinked.Id });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
|
||||
|
||||
Section(feed, NotificationReasons.NoVendor).Items
|
||||
.Select(item => item.WorkOrders.Single().Number)
|
||||
.Should().BeEquivalentTo("NV-DEL-DIRECT", "NV-DEL-LINKED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var aveta = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "AV-1");
|
||||
var earlierNoVendor = Open(1, "dispatcher-1", Now.AddHours(5), "NV-EARLY");
|
||||
var laterNoVendor = Open(1, "dispatcher-1", Now.AddHours(30), "NV-LATE");
|
||||
context.workOrders.AddRange(aveta, earlierNoVendor, laterNoVendor, Open(1));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
|
||||
|
||||
feed.GeneratedAt.Should().Be(Now);
|
||||
feed.Sections.Select(section => section.Reason).Should().Equal(
|
||||
NotificationReasons.Unassigned, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing);
|
||||
feed.Sections.Select(section => section.Severity).Should().Equal(
|
||||
NotificationSeverities.High, NotificationSeverities.Medium, NotificationSeverities.Medium);
|
||||
var noVendorNumbers = Section(feed, NotificationReasons.NoVendor).Items
|
||||
.Select(item => item.WorkOrders.Single().Number);
|
||||
noVendorNumbers.Should().ContainInOrder("NV-LATE", "NV-EARLY");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ordering_BreaksEqualSeverityTiesByReasonOrderAndPutsHigherSeverityFirst()
|
||||
{
|
||||
static NotificationItemDto Item(string id, string reason, string severity, int minutes) => new()
|
||||
{
|
||||
Id = id,
|
||||
Reason = reason,
|
||||
Severity = severity,
|
||||
Count = 1,
|
||||
TriggeredAt = Now.AddMinutes(minutes)
|
||||
};
|
||||
|
||||
var sections = NotificationFeedOrdering.Order(new (string, int, IReadOnlyList<NotificationItemDto>)[]
|
||||
{
|
||||
(NotificationReasons.AvetaMissing, 1, new[] { Item("a", NotificationReasons.AvetaMissing, NotificationSeverities.Medium, 0) }),
|
||||
(NotificationReasons.Activity, 2, new[]
|
||||
{
|
||||
Item("old-low", NotificationReasons.Activity, NotificationSeverities.Low, -10),
|
||||
Item("new-low", NotificationReasons.Activity, NotificationSeverities.Low, 10),
|
||||
Item("high", NotificationReasons.Activity, NotificationSeverities.High, -20)
|
||||
}),
|
||||
(NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>()),
|
||||
(NotificationReasons.NoVendor, 1, new[] { Item("n", NotificationReasons.NoVendor, NotificationSeverities.Medium, 0) })
|
||||
});
|
||||
|
||||
sections.Select(section => section.Reason).Should().Equal(
|
||||
NotificationReasons.Activity, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing);
|
||||
sections[0].Items.Select(item => item.Id).Should().Equal("high", "new-low", "old-low");
|
||||
sections[0].Label.Should().Be("Recent activity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatcher_OnlySeesTheirOwnWorkOrders()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.AddRange(
|
||||
AvetaDue(1, "dispatcher-1", Today.AddHours(9), "MINE"),
|
||||
AvetaDue(1, "dispatcher-2", Today.AddHours(9), "THEIRS"),
|
||||
Open(1, "dispatcher-2", Now.AddHours(5), "THEIRS-NV"));
|
||||
await context.SaveChangesAsync();
|
||||
foreach (var workOrder in context.workOrders.Where(w => w.AvetaRequired).ToList())
|
||||
AddActiveDispatch(context, workOrder);
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
|
||||
|
||||
feed.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.AvetaMissing);
|
||||
Section(feed, NotificationReasons.AvetaMissing).Items.Single().WorkOrders.Single().Number.Should().Be("MINE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CrossTenant_AccountUserNeverSeesAnotherAccountsWorkOrders()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var otherAveta = AvetaDue(2, "dispatcher-1", Today.AddHours(9), "OTHER-AV");
|
||||
var otherNoVendor = Open(2, "dispatcher-1", Now.AddHours(5), "OTHER-NV");
|
||||
var otherUnassigned = Open(2);
|
||||
var ownUnassigned = Open(1);
|
||||
context.workOrders.AddRange(otherAveta, otherNoVendor, otherUnassigned, ownUnassigned);
|
||||
await context.SaveChangesAsync();
|
||||
AddActiveDispatch(context, otherAveta);
|
||||
var vendor = context.Vendors.First();
|
||||
var overlapping = Open(2, "dispatcher-1", Now.AddHours(1), "OTHER-OVERLAP");
|
||||
context.workOrders.Add(overlapping);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = overlapping.Id, Status = "Sent", ScheduledDate = otherAveta.ScheduledDate });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var admin = await NewService(context).GetFeedAsync(User(1, "Admin"), CancellationToken.None);
|
||||
var dispatcher = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
|
||||
|
||||
admin.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.Unassigned);
|
||||
Section(admin, NotificationReasons.Unassigned).Count.Should().Be(1);
|
||||
dispatcher.Sections.Should().BeEmpty();
|
||||
var orgWide = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None);
|
||||
orgWide.Sections.Select(section => section.Reason).Should().Contain(new[]
|
||||
{
|
||||
NotificationReasons.Unassigned, NotificationReasons.NoVendor,
|
||||
NotificationReasons.AvetaMissing, NotificationReasons.VendorConflict
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VendorConflict_KeepsOverlappingAppointmentsAsTheirOwnSection()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var mine = Open(1, "dispatcher-1", Now.AddDays(3), "C-MINE");
|
||||
var theirs = Open(1, "dispatcher-2", Now.AddDays(3), "C-THEIRS");
|
||||
context.workOrders.AddRange(mine, theirs);
|
||||
await context.SaveChangesAsync();
|
||||
AddActiveDispatch(context, mine);
|
||||
var vendor = context.Vendors.Single();
|
||||
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = theirs.Id, Status = "Sent", ScheduledDate = theirs.ScheduledDate });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
|
||||
var unrelated = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-3"), CancellationToken.None);
|
||||
|
||||
var section = Section(feed, NotificationReasons.VendorConflict);
|
||||
section.Label.Should().Be("Vendor Conflict");
|
||||
section.Count.Should().Be(2);
|
||||
var item = section.Items.Single();
|
||||
item.Title.Should().Be("Gateway has overlapping appointments");
|
||||
item.WorkOrders.Select(workOrder => workOrder.Number).Should().BeEquivalentTo("C-MINE", "C-THEIRS");
|
||||
unrelated.Sections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VendorConflict_CapsByRecencyKeepingNewerOverlaps()
|
||||
{
|
||||
using var context = NewContext();
|
||||
// One vendor whose older cluster produces more overlapping pairs than the cap, plus a
|
||||
// single newer overlap. Build sweeps a vendor's windows in start order, so the newer pair
|
||||
// is emitted last and is exactly what an unsorted Take(limit) would drop.
|
||||
var oldStart = Now.AddDays(3);
|
||||
var oldOrders = new List<WorkOrder>();
|
||||
for (var index = 0; index < 11; index++)
|
||||
oldOrders.Add(Open(1, "dispatcher-1", oldStart, $"OLD-{index}"));
|
||||
var newerLeft = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-A");
|
||||
var newerRight = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-B");
|
||||
context.workOrders.AddRange(oldOrders);
|
||||
context.workOrders.AddRange(newerLeft, newerRight);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
context.Vendors.Add(vendor);
|
||||
await context.SaveChangesAsync();
|
||||
foreach (var workOrder in oldOrders.Append(newerLeft).Append(newerRight))
|
||||
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledDate });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
|
||||
|
||||
var section = Section(feed, NotificationReasons.VendorConflict);
|
||||
section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit);
|
||||
section.Items.Should().Contain(item =>
|
||||
item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-A")
|
||||
&& item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-B"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var total = NotificationFeedService.SectionItemLimit + 5;
|
||||
for (var index = 0; index < total; index++)
|
||||
context.workOrders.Add(Open(1, "dispatcher-1", Now.AddHours(1).AddMinutes(index), $"B-{index}"));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
|
||||
|
||||
var section = Section(feed, NotificationReasons.NoVendor);
|
||||
section.Count.Should().Be(total);
|
||||
section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingAccountScope_FailsClosed()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "user-1"),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
}, "test"));
|
||||
|
||||
var act = () => NewService(context).GetFeedAsync(user, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownRole_FailsClosed()
|
||||
{
|
||||
using var context = NewContext();
|
||||
|
||||
var act = () => NewService(context).GetFeedAsync(User(1, "Vendor"), CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_ReachesTheFeedQueries()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.Add(Open(1));
|
||||
await context.SaveChangesAsync();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
var act = () => NewService(context).GetFeedAsync(User(1, "Admin"), cancellation.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_MapsForbiddenScopeTo403()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var controller = new NotificationsController(NewService(context))
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = User(1, "Vendor") }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetFeed(CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_ReturnsTheFeedForAnAuthorizedUser()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.Add(Open(1));
|
||||
await context.SaveChangesAsync();
|
||||
var controller = new NotificationsController(NewService(context))
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = User(1, "Admin") }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetFeed(CancellationToken.None);
|
||||
|
||||
var feed = result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeOfType<NotificationFeedDto>().Subject;
|
||||
feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Controller_RequiresAnAuthenticatedCaller()
|
||||
{
|
||||
typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||
.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
|
|
@ -282,6 +282,92 @@ public sealed class UpliftQueueReadTests
|
|||
item.Status.Should().Be("Pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsDetailModalWorkOrderContext_DispatcherTechnicianAndScheduledDate()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-81", "SITE-N", "Plumbing");
|
||||
vendor.ContactName = " Tom Tech ";
|
||||
workOrder.ScheduledDate = new DateTime(2026, 4, 10, 9, 30, 0);
|
||||
workOrder.AssignTo = "dispatcher-1";
|
||||
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Dana", LastName = "Ruiz" });
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-81");
|
||||
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 4, 10, 9, 30, 0));
|
||||
item.WorkOrderDispatcherName.Should().Be("Dana Ruiz");
|
||||
item.TechnicianName.Should().Be("Tom Tech");
|
||||
item.VendorCompanyName.Should().Be("Gateway");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_DetailModalWorkOrderContext_IsNullForUnassignedUnscheduledWorkOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-82", "SITE-N", "Plumbing");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-82");
|
||||
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderScheduledDate.Should().BeNull();
|
||||
item.WorkOrderDispatcherName.Should().BeNull();
|
||||
item.TechnicianName.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_DetailModalWorkOrderContext_NeverCrossesAccounts()
|
||||
{
|
||||
// Two tenants' work orders sit side by side. Each queue row must carry the
|
||||
// dispatcher, technician and schedule of its own work order and vendor only;
|
||||
// another account's work order never supplies them.
|
||||
using var context = NewContext();
|
||||
var accountA = new Accounts { Name = "Account A" };
|
||||
var accountB = new Accounts { Name = "Account B" };
|
||||
context.AddRange(accountA, accountB);
|
||||
context.Users.AddRange(
|
||||
new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" },
|
||||
new ApplicationUser { Id = "dispatcher-b", FirstName = "Ben", LastName = "Beta" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Vendor A");
|
||||
vendorA.ContactName = "Tech A";
|
||||
workOrderA.AccountId = accountA.Id;
|
||||
workOrderA.AssignTo = "dispatcher-a";
|
||||
workOrderA.ScheduledDate = new DateTime(2026, 5, 1);
|
||||
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Roofing", "Vendor B");
|
||||
vendorB.ContactName = "Tech B";
|
||||
workOrderB.AccountId = accountB.Id;
|
||||
workOrderB.AssignTo = "dispatcher-b";
|
||||
workOrderB.ScheduledDate = new DateTime(2026, 6, 1);
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatchA, "Pending", new DateTime(2026, 4, 1)),
|
||||
Request(dispatchB, "Pending", new DateTime(2026, 4, 2)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var rowA = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderA.Id).Subject;
|
||||
rowA.WorkOrderDispatcherName.Should().Be("Ann Alpha");
|
||||
rowA.TechnicianName.Should().Be("Tech A");
|
||||
rowA.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 5, 1));
|
||||
var rowB = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderB.Id).Subject;
|
||||
rowB.WorkOrderDispatcherName.Should().Be("Ben Beta");
|
||||
rowB.TechnicianName.Should().Be("Tech B");
|
||||
rowB.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 6, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests
|
|||
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||
Mock.Of<IWorkOrderPocService>(),
|
||||
advancedSearch);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ public class WorkOrderRouteContractTests
|
|||
"PATCH {id:int}/board",
|
||||
"PATCH {id:int}/comments/{commentId:int}",
|
||||
"PATCH {id:int}/media/{mediaId:int}",
|
||||
"PATCH {id:int}/poc",
|
||||
"POST AddChecklistItem",
|
||||
"POST AddComment",
|
||||
"POST AddCommentJson",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/notifications")]
|
||||
public class NotificationsController : ControllerBase
|
||||
{
|
||||
private readonly INotificationFeedService _feedService;
|
||||
|
||||
public NotificationsController(INotificationFeedService feedService)
|
||||
{
|
||||
_feedService = feedService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetFeed(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _feedService.GetFeedAsync(User, cancellationToken));
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
||||
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
||||
private readonly IWorkOrderPocService _pocService;
|
||||
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
|
||||
|
||||
public WorkOrderBoardController(
|
||||
|
|
@ -31,12 +32,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
IWorkOrderBoardUpdateService boardUpdateService,
|
||||
IWorkOrderBoardCreateService boardCreateService,
|
||||
IWorkOrderBoardCancelService boardCancelService,
|
||||
IWorkOrderPocService pocService,
|
||||
IWorkOrderAdvancedSearchService advancedSearchService)
|
||||
{
|
||||
_workOrderBoardService = workOrderBoardService;
|
||||
_boardUpdateService = boardUpdateService;
|
||||
_boardCreateService = boardCreateService;
|
||||
_boardCancelService = boardCancelService;
|
||||
_pocService = pocService;
|
||||
_advancedSearchService = advancedSearchService;
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +168,56 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SH-379: replaces the WO-level POC (name, phone, notes) with audit entries.
|
||||
/// Blank name+phone clears the override so the work order follows the Site.
|
||||
/// </summary>
|
||||
[HttpPatch("{id:int}/poc")]
|
||||
public async Task<IActionResult> UpdateWorkOrderPoc(int id, [FromBody] WorkOrderPocUpdateRequestDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _pocService.UpdatePocAsync(id, request, User, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (WorkOrderBoardConcurrencyException ex)
|
||||
{
|
||||
return Conflict(new WorkOrderBoardConflictDto
|
||||
{
|
||||
CurrentState = ex.CurrentState
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("board")]
|
||||
public async Task<IActionResult> CreateBoardWorkOrder(
|
||||
[FromBody] WorkOrderBoardCreateRequestDto request,
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
# Backend Architecture and Quality Rules
|
||||
|
||||
This repository uses a feature-oriented service boundary over Entity Framework Core:
|
||||
|
||||
```text
|
||||
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
|
||||
```
|
||||
|
||||
The interfaces are architectural seams, not a requirement to wrap every class or every
|
||||
EF method. Add an interface when it separates HTTP, business, persistence, or external
|
||||
I/O responsibilities and enables behavior-focused testing.
|
||||
|
||||
## Layer responsibilities
|
||||
|
||||
### API controllers
|
||||
|
||||
- Parse HTTP input, authorize the caller, invoke a business-service interface, and map
|
||||
the result to the established HTTP contract.
|
||||
- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete
|
||||
data-service implementations.
|
||||
- Do not contain persistence queries or business workflows.
|
||||
- Do not expose exception messages to clients. Map known failures explicitly and let the
|
||||
centralized exception boundary handle unexpected failures.
|
||||
|
||||
### Business services
|
||||
|
||||
- Own validation, business decisions, orchestration, and transaction intent.
|
||||
- Depend on interfaces for persistence and external I/O.
|
||||
- Use `IOptions<T>` for typed configuration. Do not inject `IConfiguration`.
|
||||
- Keep feature responsibilities cohesive. Split a service when it has independently
|
||||
changing business reasons, not because it crossed an arbitrary line count.
|
||||
- Preserve existing API and board-backed behavior unless the accepted requirement
|
||||
explicitly changes it.
|
||||
|
||||
### Data services
|
||||
|
||||
- Own EF Core query shape, persistence, batching, and transaction implementation.
|
||||
- Accept cancellation tokens on new I/O methods and pass them to EF Core.
|
||||
- Use `AsNoTracking()` for read-only queries.
|
||||
- Project only required columns for list/read models; include full graphs only when the
|
||||
caller needs them.
|
||||
- Page unbounded collections at the database.
|
||||
- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes
|
||||
while preserving the workflow's failure and transaction semantics.
|
||||
- Do not introduce a generic repository over EF Core. Feature data services should expose
|
||||
intent-revealing operations.
|
||||
|
||||
## Performance review
|
||||
|
||||
For each changed hot path, document and test:
|
||||
|
||||
- input size variables;
|
||||
- CPU and memory complexity;
|
||||
- database round trips;
|
||||
- whether filtering, ordering, and paging execute in SQL;
|
||||
- whether tracking is necessary;
|
||||
- whether repeated work can be hoisted out of loops;
|
||||
- the partial-failure and transaction behavior.
|
||||
|
||||
Prefer dictionary or hash-set reconstruction when database results must follow caller
|
||||
order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory;
|
||||
repeated `First`/`Single` scans over the result are `O(n²)`.
|
||||
|
||||
Do not optimize by weakening correctness. In particular, batching all writes into one
|
||||
final save can change partial-success behavior, and adding a transaction can change
|
||||
locking and retry semantics. Those are business decisions, not mechanical cleanup.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is bound once during composition in
|
||||
`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive
|
||||
typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and
|
||||
`VendorPortalOptions`.
|
||||
|
||||
Defaults must preserve current behavior. Startup validation should be introduced only
|
||||
when every deployed environment is known to provide the required value.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`ArchitectureTests` enforces mechanical dependency rules:
|
||||
|
||||
- controllers cannot inject EF contexts or concrete service/data-service classes;
|
||||
- business services cannot inject EF contexts or raw `IConfiguration`.
|
||||
|
||||
The pull-request quality workflow runs those tests and verifies formatting for changed
|
||||
C# files. The normal CI workflow builds the solution and runs the full test suite.
|
||||
|
||||
Architecture tests are appropriate for dependency direction. Behavior belongs in tests
|
||||
through public interfaces. Do not add tests merely to prove a linter or analyzer itself
|
||||
works; test the product behavior the rule protects.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- Controller depends only on service abstractions.
|
||||
- Business logic is in a cohesive feature service.
|
||||
- EF operations are behind a feature data-service abstraction.
|
||||
- No raw `IConfiguration` in a business service.
|
||||
- No unbounded load followed by in-memory filtering.
|
||||
- No query/save loop where a set-based operation preserves semantics.
|
||||
- Read-only EF queries use no tracking.
|
||||
- New async I/O accepts and propagates cancellation where the public contract permits.
|
||||
- Errors do not leak internal exception details.
|
||||
- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing
|
||||
records, and relevant failure boundaries.
|
||||
- Changed behavior has been checked against the authoritative ticket board. Missing board
|
||||
access blocks readiness and merge claims.
|
||||
|
|
@ -85,6 +85,12 @@ namespace Data.SeaHavenIndustries
|
|||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSiteCode { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
// SH-209: detail modal context, resolved from the same effective work order
|
||||
// and the requesting dispatch's vendor.
|
||||
public DateTime? WorkOrderScheduledDate { get; set; }
|
||||
public string? WorkOrderDispatcherFirstName { get; set; }
|
||||
public string? WorkOrderDispatcherLastName { get; set; }
|
||||
public string? TechnicianName { get; set; }
|
||||
public bool WorkOrderClosed { get; set; }
|
||||
public int AttachmentCount { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
|
|
|
|||
115
README.md
Normal file
115
README.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# SHOC Backend (`shoc-backend`)
|
||||
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-backend/actions/workflows/ci.yml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-backend/actions/workflows/deploy.yaml)
|
||||

|
||||

|
||||

|
||||
|
||||
ASP.NET Core 8 API for Sea Haven facility management (SHOC): work orders, the
|
||||
work-order board, dispatches and the vendor portal, uplift approvals,
|
||||
notifications, and the dashboard. It is the backend for
|
||||
[`shoc-frontend-new`](https://github.com/Sea-Haven-Industries/shoc-frontend-new),
|
||||
which calls it directly over HTTPS.
|
||||
|
||||
| Environment | API | Elastic Beanstalk environment | Deployed by |
|
||||
|---|---|---|---|
|
||||
| dev | `https://api.dev.seahaven.com` | `shoc-backend-dev` | every push to `main` |
|
||||
| staging | `https://api.staging.seahaven.com` | `shoc-backend-staging` | a `vX.Y.Z-staging` tag cut with `release.yaml` |
|
||||
|
||||
Both run in `us-east-1` on the .NET 8 Amazon Linux 2023 platform. Terraform in
|
||||
`terraform/live/` owns the environments; GitHub Actions owns the application
|
||||
versions. There is no production environment yet.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
|
||||
```
|
||||
|
||||
Controllers depend on feature service interfaces only. Business services depend
|
||||
on feature data-service interfaces, never on `DbContext`. Data services own
|
||||
Entity Framework Core and are the atomic commit boundary. Tenant scope is
|
||||
derived on the server from claims, and authorization is enforced at service
|
||||
entry. The rules and their reasons are in
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md).
|
||||
|
||||
| Project | Role |
|
||||
|---|---|
|
||||
| `Api.SeaHavenIndustries` | The deployed API: controllers, hosted services, infrastructure adapters, `Program.cs` |
|
||||
| `SeaHaven.Services` | Business services, DTOs, validation, helpers |
|
||||
| `SeaHaven.DataServices` | Feature data services over EF Core |
|
||||
| `Data.SeaHavenIndustries` | Entities, `ApplicationDbContext`, Identity, migrations |
|
||||
| `SeaHavenIndustries` | Legacy Blazor Server app sharing the data layer; not part of the API deployment |
|
||||
| `Api.SeaHavenIndustries.Tests`, `SeaHaven.Services.Tests`, `SeaHavenIndustries.Tests` | xunit test projects |
|
||||
|
||||
## Local development
|
||||
|
||||
Requires the .NET 8 SDK and a reachable SQL Server. Configuration comes from
|
||||
`appsettings*.json` placeholders, then user secrets, then environment
|
||||
variables. `.env.example` lists every key, including the connection string, JWT
|
||||
secret, SendGrid key and Sentry DSN. Do not commit real values.
|
||||
|
||||
```bash
|
||||
dotnet restore SeaHavenIndustries.sln
|
||||
dotnet build SeaHavenIndustries.sln --configuration Release
|
||||
dotnet test SeaHavenIndustries.sln --configuration Release
|
||||
dotnet run --project Api.SeaHavenIndustries
|
||||
```
|
||||
|
||||
The full repository gate that CI runs, including architecture discovery, the
|
||||
changed-file maintainability check, Terraform checks and app/Terraform
|
||||
isolation:
|
||||
|
||||
```bash
|
||||
BASE_REF=origin/main bash scripts/governance-check.sh
|
||||
```
|
||||
|
||||
Migrations live in `Data.SeaHavenIndustries/Migrations` and are applied at
|
||||
deploy time from a bundle the packaging script builds with `dotnet-ef`. Adding
|
||||
one follows the G6 rules in [`QUALITY_GATES.md`](QUALITY_GATES.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
- Branch from `main` with a `feature/`, `fix/`, `chore/`, `docs/` or
|
||||
`refactor/` prefix and a kebab-case description.
|
||||
- Commit subjects follow Conventional Commits. PR titles end with the Jira
|
||||
key for product work.
|
||||
- The PR body uses the three-section layout the template pre-fills: Summary,
|
||||
Changes and value, Ticket. The reasoning is in
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- `main` requires a code-owner review and the `Build and test`,
|
||||
`architecture` and `review / dependency-review` checks. PRs merge through the
|
||||
merge queue, so a branch does not need to be updated with `main` before it
|
||||
merges.
|
||||
- Application code and `terraform/` do not change in the same PR (G13).
|
||||
|
||||
## Deployment
|
||||
|
||||
`deploy.yaml` packages the API with `scripts/package-elastic-beanstalk.sh`,
|
||||
uploads the bundle to the Elastic Beanstalk bucket, updates the environment,
|
||||
verifies the exact version is active, and smoke-tests it. The bundle carries a
|
||||
self-contained EF Core migrations bundle that Elastic Beanstalk runs on the
|
||||
leader instance before the new version starts
|
||||
(`.ebextensions/01_migrations.config`). A push to `main` targets dev.
|
||||
`release.yaml` cuts a SemVer tag from `main` and calls the same workflow for
|
||||
staging. Both are described in the workflow headers and in
|
||||
[`terraform/live/README.md`](terraform/live/README.md).
|
||||
|
||||
Renovate opens dependency PRs on the schedule in `.github/renovate.json`;
|
||||
majors wait for approval on the Dependency Dashboard issue.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`AGENTS.md`](AGENTS.md): repo-specific rules for coding agents and the order
|
||||
of precedence between the governance documents.
|
||||
- [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md): what
|
||||
the architecture rules mean.
|
||||
- [`QUALITY_GATES.md`](QUALITY_GATES.md): every gate, the command that runs it
|
||||
locally, and where CI runs it.
|
||||
- [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md): review order,
|
||||
evidence, and the PR description contract.
|
||||
- [`docs/adr/`](docs/adr/): architecture decision records.
|
||||
- [`docs/work-orders/`](docs/work-orders/): work-order board phase notes.
|
||||
- [`postman/`](postman/): generated Postman collection and dev environment.
|
||||
- [`terraform/README.md`](terraform/README.md): infrastructure runbook.
|
||||
|
|
@ -130,6 +130,11 @@ Avoid boilerplate: no deployment notes, no validation transcripts, no
|
|||
"residual-risk" theatre, no AI signatures. The validation story lives in the
|
||||
check run results and the close-out, not in the PR body.
|
||||
|
||||
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
|
||||
template, whose Summary / Validation / Tests / Notes headings this repository
|
||||
does not use. The org `callable-pr-policy` workflow hard-codes those four
|
||||
headings; it is not wired into this repository, and this layout is the reason.
|
||||
|
||||
## 9. Merge readiness
|
||||
|
||||
Merge requires: exact-head review done; board-backed regression check pass (or
|
||||
|
|
|
|||
|
|
@ -52,6 +52,20 @@ namespace SeaHaven.DataServices.Helpers
|
|||
c.Notes
|
||||
})
|
||||
.FirstOrDefault(),
|
||||
// SH-379/SH-190: the Site's live primary contact backs the
|
||||
// follow-the-site POC display when no override or link exists.
|
||||
SitePoc = w.Locations != null
|
||||
? w.Locations.Contacts!
|
||||
.Where(c => c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.Select(c => new
|
||||
{
|
||||
Name = ((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim(),
|
||||
c.PhoneNumber
|
||||
})
|
||||
.FirstOrDefault()
|
||||
: null,
|
||||
w.LifecycleStatus,
|
||||
w.LegacyStatus,
|
||||
w.Status,
|
||||
|
|
@ -116,11 +130,11 @@ namespace SeaHaven.DataServices.Helpers
|
|||
: null;
|
||||
var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault();
|
||||
var pocName = primaryFrozenPoc?.Name
|
||||
?? (!string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name);
|
||||
?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name);
|
||||
var pocPhone = primaryFrozenPoc?.Phone
|
||||
?? (!string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber);
|
||||
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
|
||||
var pocNotes = frozenPoc?.Notes
|
||||
?? (!string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes);
|
||||
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
|
||||
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
|
||||
? w.DispatchTechPhone
|
||||
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
|
||||
|
|
@ -188,10 +202,15 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.MediaCount,
|
||||
frozenSite,
|
||||
frozenCompany,
|
||||
frozenPoc);
|
||||
frozenPoc,
|
||||
w.WoPocName,
|
||||
w.WoPocPhone);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string? FirstNotBlank(params string?[] values)
|
||||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
|
||||
private static T? Deserialize<T>(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class NotificationFeedDataService : INotificationFeedDataService
|
||||
{
|
||||
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)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<UnassignedWorkOrdersSummary> GetUnassignedSummaryAsync(
|
||||
int? accountId, CancellationToken cancellationToken)
|
||||
{
|
||||
var summary = await OpenWorkOrders(new NotificationFeedScope(accountId, null))
|
||||
.Where(w => w.AssignTo == null || w.AssignTo == "")
|
||||
.GroupBy(_ => 1)
|
||||
.Select(group => new UnassignedWorkOrdersSummary
|
||||
{
|
||||
Count = group.Count(),
|
||||
LatestCreatedAt = group.Max(w => w.CreatedDate)
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return summary ?? new UnassignedWorkOrdersSummary();
|
||||
}
|
||||
|
||||
public Task<NotificationCandidatePage> GetAvetaMissingAsync(
|
||||
NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
// Same rule as the dashboard's Aveta Pending tile so the two counts agree.
|
||||
var windowStart = today.ToDateTime(TimeOnly.MinValue);
|
||||
var windowEnd = today.AddDays(2).ToDateTime(TimeOnly.MinValue);
|
||||
var candidates = OpenWorkOrders(scope)
|
||||
.Where(w => w.AvetaRequired
|
||||
&& w.ScheduledDate >= windowStart
|
||||
&& w.ScheduledDate < windowEnd
|
||||
&& !w.workOrderAttachments!.Any(attachment =>
|
||||
(attachment.IsDeleted == null || attachment.IsDeleted == false)
|
||||
&& attachment.Category == WorkOrderMediaCategory.Aveta));
|
||||
|
||||
return PageAsync(candidates, limit, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<NotificationCandidatePage> GetNoVendorAsync(
|
||||
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
// "Has a vendor" matches the vendor reminders: any live non-terminal dispatch that
|
||||
// points at the work order directly or links it through DispatchWorkOrders. Soft-deleted
|
||||
// dispatches are ignored so a removed dispatch does not keep the row out of No Vendor.
|
||||
var candidates = OpenWorkOrders(scope)
|
||||
.Where(w => w.ScheduledDate.HasValue
|
||||
&& w.ScheduledDate.Value <= cutoff
|
||||
&& !_context.Dispatches.Any(d => d.WorkOrderId == w.Id
|
||||
&& (d.IsDeleted == null || d.IsDeleted == false)
|
||||
&& !TerminalDispatchStatuses.Contains(d.Status ?? ""))
|
||||
&& !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id
|
||||
&& link.Dispatch != null
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")));
|
||||
|
||||
return PageAsync(candidates, limit, cancellationToken);
|
||||
}
|
||||
|
||||
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());
|
||||
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);
|
||||
|
||||
return workOrders.Where(w => w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& (w.LifecycleStatus != null
|
||||
|| ((w.LegacyStatus ?? w.Status) != "Done"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Completed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Complete"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Closed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Canceled"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Cancelled")));
|
||||
}
|
||||
|
||||
private static async Task<NotificationCandidatePage> PageAsync(
|
||||
IQueryable<WorkOrder> candidates, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var total = await candidates.CountAsync(cancellationToken);
|
||||
if (total == 0)
|
||||
return new NotificationCandidatePage();
|
||||
|
||||
var items = await candidates
|
||||
.OrderByDescending(w => w.ScheduledDate)
|
||||
.ThenByDescending(w => w.Id)
|
||||
.Take(limit)
|
||||
.Select(w => new NotificationWorkOrderCandidate
|
||||
{
|
||||
Id = w.Id,
|
||||
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||||
ScheduledDate = w.ScheduledDate!.Value
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new NotificationCandidatePage { Total = total, Items = items };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,18 @@ namespace SeaHaven.DataServices.Implementation
|
|||
WorkOrderNumber = x.workOrder != null ? x.workOrder.InternalWONumber : null,
|
||||
WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null,
|
||||
WorkOrderService = x.workOrder != null ? x.workOrder.Service : null,
|
||||
// SH-209: the detail modal reads its work-order context from this row,
|
||||
// so it never depends on a separate work-order fetch. Technician
|
||||
// follows the board convention (vendor contact) for the requesting
|
||||
// dispatch's vendor, the same vendor as VendorCompanyName.
|
||||
WorkOrderScheduledDate = x.workOrder != null ? x.workOrder.ScheduledDate : null,
|
||||
WorkOrderDispatcherFirstName = x.workOrder != null && x.workOrder.AssignToUser != null
|
||||
? x.workOrder.AssignToUser.FirstName
|
||||
: null,
|
||||
WorkOrderDispatcherLastName = x.workOrder != null && x.workOrder.AssignToUser != null
|
||||
? x.workOrder.AssignToUser.LastName
|
||||
: null,
|
||||
TechnicianName = x.v != null ? x.v.ContactName : null,
|
||||
// SH-208: a work order is closed for uplift decisions once its
|
||||
// lifecycle reaches a terminal state; mirrors the SH-196 revoke guard.
|
||||
WorkOrderClosed = x.workOrder != null
|
||||
|
|
|
|||
|
|
@ -23,12 +23,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
var directlyAssignedIds = await _context.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "")
|
||||
&& (d.IsDeleted == null || d.IsDeleted == false)
|
||||
&& d.WorkOrderId.HasValue)
|
||||
.Select(d => d.WorkOrderId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
var multiplyAssignedIds = await _context.DispatchWorkOrders
|
||||
.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||
.Select(link => link.WorkOrderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
|
@ -56,14 +59,23 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken)
|
||||
public Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken)
|
||||
=> GetActiveScheduledDispatchesAsync(null, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(
|
||||
int? accountId, CancellationToken cancellationToken)
|
||||
{
|
||||
var direct = _context.Dispatches.AsNoTracking()
|
||||
var directDispatches = _context.Dispatches.AsNoTracking()
|
||||
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
|
||||
&& dispatch.WorkOrderId.HasValue
|
||||
&& (dispatch.ScheduledDate.HasValue
|
||||
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
|
||||
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)));
|
||||
if (accountId is int directAccountId)
|
||||
directDispatches = directDispatches.Where(dispatch =>
|
||||
dispatch.WorkOrder != null && dispatch.WorkOrder.AccountId == directAccountId);
|
||||
|
||||
var direct = directDispatches
|
||||
.Select(dispatch => new ScheduledDispatchReadModel
|
||||
{
|
||||
Id = dispatch.Id,
|
||||
|
|
@ -71,18 +83,23 @@ namespace SeaHaven.DataServices.Implementation
|
|||
VendorName = dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
|
||||
AssignTo = dispatch.WorkOrder.AssignTo,
|
||||
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
|
||||
End = dispatch.WorkOrder.ScheduledEnd
|
||||
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
var linked = _context.DispatchWorkOrders.AsNoTracking()
|
||||
var linkedDispatches = _context.DispatchWorkOrders.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& link.WorkOrder != null
|
||||
&& link.Dispatch.WorkOrderId != link.WorkOrderId
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
|
||||
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue));
|
||||
if (accountId is int linkedAccountId)
|
||||
linkedDispatches = linkedDispatches.Where(link => link.WorkOrder!.AccountId == linkedAccountId);
|
||||
|
||||
var linked = linkedDispatches
|
||||
.Select(link => new ScheduledDispatchReadModel
|
||||
{
|
||||
Id = link.DispatchId,
|
||||
|
|
@ -90,6 +107,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
VendorName = link.Dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = link.WorkOrderId,
|
||||
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
|
||||
AssignTo = link.WorkOrder.AssignTo,
|
||||
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
|
||||
End = link.WorkOrder.ScheduledEnd
|
||||
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders
|
||||
.Include(workOrder => workOrder.Locations)
|
||||
.Include(workOrder => workOrder.Locations!)
|
||||
.ThenInclude(location => location.Contacts)
|
||||
.Include(workOrder => workOrder.WorkOrderContacts!)
|
||||
.ThenInclude(contact => contact.POC)
|
||||
.Include(workOrder => workOrder.PrimaryDispatch!)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderPocDataService : IWorkOrderPocDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderPocDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken)
|
||||
=> _context.workOrders
|
||||
.Include(workOrder => workOrder.Locations!)
|
||||
.ThenInclude(location => location.Contacts)
|
||||
.Include(workOrder => workOrder.WorkOrderContacts!)
|
||||
.ThenInclude(contact => contact.POC)
|
||||
.FirstOrDefaultAsync(
|
||||
w => w.Id == workOrderId
|
||||
&& w.istemplate != true
|
||||
&& (w.IsDeleted != true || w.IsDeleted == null)
|
||||
&& (accountId == null || w.AccountId == accountId),
|
||||
cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
|
||||
int? locationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (locationId == null)
|
||||
return Array.Empty<Contacts>();
|
||||
|
||||
return await _context.Contacts
|
||||
.AsNoTracking()
|
||||
.Where(c => c.LocationId == locationId && c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Live work-order conditions behind the notification feed. Every query applies the
|
||||
/// base scope (no templates, no deleted rows), the caller's account scope, and the
|
||||
/// shared open rule (not Completed/Canceled, including legacy terminal statuses).
|
||||
/// </summary>
|
||||
public interface INotificationFeedDataService
|
||||
{
|
||||
/// <summary>Open work orders with no dispatcher assigned in <paramref name="accountId"/> (null is org-wide).</summary>
|
||||
Task<UnassignedWorkOrdersSummary> GetUnassignedSummaryAsync(int? accountId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Open, Aveta-required work orders scheduled on <paramref name="today"/> or the next day with no
|
||||
/// Aveta document attached. Items are the <paramref name="limit"/> latest scheduled.
|
||||
/// </summary>
|
||||
Task<NotificationCandidatePage> GetAvetaMissingAsync(
|
||||
NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Open work orders scheduled at or before <paramref name="cutoff"/> with no active vendor dispatch,
|
||||
/// direct or linked. Items are the <paramref name="limit"/> latest scheduled.
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken);
|
||||
/// <summary>Same windows limited to work orders of <paramref name="accountId"/>; null is org-wide.</summary>
|
||||
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(int? accountId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken);
|
||||
Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken);
|
||||
|
|
|
|||
28
SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs
Normal file
28
SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379: data access for the work-order-level POC override.
|
||||
/// </summary>
|
||||
public interface IWorkOrderPocDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-221 account-scoped tracked load for POC updates. Includes the site's
|
||||
/// contacts and the linked WorkOrderContacts POC. Null when the work order
|
||||
/// does not exist or is outside the caller's account scope.
|
||||
/// </summary>
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// The site's live contacts for a location ordered by SiteContactOrder then Id.
|
||||
/// Deleted contacts are excluded. Empty when the location has none.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
|
||||
int? locationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +71,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int MediaCount = 0,
|
||||
WorkOrderFrozenSite? FrozenSite = null,
|
||||
WorkOrderFrozenCompany? FrozenCompany = null,
|
||||
WorkOrderFrozenPoc? FrozenPoc = null);
|
||||
WorkOrderFrozenPoc? FrozenPoc = null,
|
||||
string? WoPocName = null,
|
||||
string? WoPocPhone = null);
|
||||
|
||||
public record WorkOrderBoardQueryResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
|
||||
|
|
|
|||
66
SeaHaven.DataServices/Models/NotificationFeedReadModels.cs
Normal file
66
SeaHaven.DataServices/Models/NotificationFeedReadModels.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
namespace SeaHaven.DataServices.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Server-derived feed scope. <see cref="AccountId"/> null is org-wide;
|
||||
/// <see cref="DispatcherId"/> null means every dispatcher's work orders.
|
||||
/// </summary>
|
||||
public sealed record NotificationFeedScope(int? AccountId, string? DispatcherId);
|
||||
|
||||
public sealed class UnassignedWorkOrdersSummary
|
||||
{
|
||||
public int Count { get; init; }
|
||||
public DateTime? LatestCreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class NotificationWorkOrderCandidate
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string? Number { get; init; }
|
||||
public DateTime ScheduledDate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A bounded slice of candidates plus the unbounded total they were taken from.</summary>
|
||||
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; }
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ public sealed class ScheduledDispatchReadModel
|
|||
public string? VendorName { get; set; }
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? AssignTo { get; set; }
|
||||
public DateTime Start { get; set; }
|
||||
public DateTime End { get; set; }
|
||||
}
|
||||
|
|
|
|||
134
SeaHaven.Services/DTOs/NotificationFeedDTOs.cs
Normal file
134
SeaHaven.Services/DTOs/NotificationFeedDTOs.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
/// <summary>
|
||||
/// Feed reasons in their fixed tie-break order. Every reason the Notification Center
|
||||
/// can show is listed here so a new producer only adds items, never a contract change.
|
||||
/// </summary>
|
||||
public static class NotificationReasons
|
||||
{
|
||||
public const string SeverityCritical = "severity-critical";
|
||||
public const string Sla = "sla";
|
||||
public const string Mentions = "mentions";
|
||||
public const string Unassigned = "unassigned";
|
||||
public const string DueSoon = "due-soon";
|
||||
public const string NoVendor = "no-vendor";
|
||||
public const string AvetaMissing = "aveta-missing";
|
||||
public const string Activity = "activity";
|
||||
public const string VendorConflict = "vendor-conflict";
|
||||
|
||||
public static readonly IReadOnlyList<string> Order = new[]
|
||||
{
|
||||
SeverityCritical, Sla, Mentions, Unassigned, DueSoon, NoVendor, AvetaMissing, Activity, VendorConflict
|
||||
};
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, string> Labels = new Dictionary<string, string>
|
||||
{
|
||||
[SeverityCritical] = "Critical severity",
|
||||
[Sla] = "SLA at Risk",
|
||||
[Mentions] = "Mentions",
|
||||
[Unassigned] = "Unassigned",
|
||||
[DueSoon] = "Due Soon",
|
||||
[NoVendor] = "No Vendor",
|
||||
[AvetaMissing] = "Aveta Missing",
|
||||
[Activity] = "Recent activity",
|
||||
[VendorConflict] = "Vendor Conflict"
|
||||
};
|
||||
}
|
||||
|
||||
public static class NotificationSeverities
|
||||
{
|
||||
public const string Critical = "Critical";
|
||||
public const string High = "High";
|
||||
public const string Medium = "Medium";
|
||||
public const string Low = "Low";
|
||||
|
||||
public static int Rank(string severity) => severity switch
|
||||
{
|
||||
Critical => 3,
|
||||
High => 2,
|
||||
Medium => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a row leaves the feed. <see cref="Dismissable"/> rows are hidden client-side for the
|
||||
/// session; <see cref="Acknowledge"/> rows (a missed response deadline) are excluded from
|
||||
/// dismiss and dismiss-all and leave only through their own acknowledge action.
|
||||
/// </summary>
|
||||
public static class NotificationRowTypes
|
||||
{
|
||||
public const string Dismissable = "dismissable";
|
||||
public const string Acknowledge = "acknowledge";
|
||||
}
|
||||
|
||||
public static class NotificationTargetKinds
|
||||
{
|
||||
public const string WorkOrder = "workOrder";
|
||||
public const string Queue = "queue";
|
||||
}
|
||||
|
||||
/// <summary>Named work-order queues a grouped item opens.</summary>
|
||||
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>
|
||||
public static class NotificationWorkOrderTabs
|
||||
{
|
||||
public const string Info = "info";
|
||||
public const string Comments = "comments";
|
||||
public const string Extras = "extras";
|
||||
public const string Uplifts = "uplifts";
|
||||
}
|
||||
|
||||
public sealed class NotificationTargetDto
|
||||
{
|
||||
public string Kind { get; init; } = NotificationTargetKinds.WorkOrder;
|
||||
public int? WorkOrderId { get; init; }
|
||||
public string? Tab { get; init; }
|
||||
public string? Queue { get; init; }
|
||||
}
|
||||
|
||||
public sealed class NotificationWorkOrderRefDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string? Number { get; init; }
|
||||
}
|
||||
|
||||
public sealed class NotificationItemDto
|
||||
{
|
||||
/// <summary>Stable across reads while the condition holds; the client dismisses by it.</summary>
|
||||
public string Id { get; init; } = "";
|
||||
public string Reason { get; init; } = "";
|
||||
public string Severity { get; init; } = NotificationSeverities.Low;
|
||||
public string RowType { get; init; } = NotificationRowTypes.Dismissable;
|
||||
public string Title { get; init; } = "";
|
||||
/// <summary>Work orders this item represents.</summary>
|
||||
public int Count { get; init; }
|
||||
/// <summary>When the condition started; drives recency ordering.</summary>
|
||||
public DateTime TriggeredAt { get; init; }
|
||||
public NotificationTargetDto Target { get; init; } = new();
|
||||
public IReadOnlyList<NotificationWorkOrderRefDto> WorkOrders { get; init; } = Array.Empty<NotificationWorkOrderRefDto>();
|
||||
}
|
||||
|
||||
public sealed class NotificationSectionDto
|
||||
{
|
||||
public string Reason { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
/// <summary>Highest item severity in the section.</summary>
|
||||
public string Severity { get; init; } = NotificationSeverities.Low;
|
||||
/// <summary>Work orders the whole section represents, including items beyond the returned slice.</summary>
|
||||
public int Count { get; init; }
|
||||
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
|
||||
}
|
||||
|
||||
public sealed class NotificationFeedDto
|
||||
{
|
||||
public DateTime GeneratedAt { get; init; }
|
||||
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSite { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
// SH-209: detail modal work-order context.
|
||||
public DateTime? WorkOrderScheduledDate { get; set; }
|
||||
public string? WorkOrderDispatcherName { get; set; }
|
||||
public string? TechnicianName { get; set; }
|
||||
public string? RequestedByName { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? PocName { get; set; }
|
||||
public string? PocPhone { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>SH-190: a manual WO-level POC override is present (locked away from the Site).</summary>
|
||||
public bool PocCustomized { get; set; }
|
||||
public List<WorkOrderAdditionalContactDto>? AdditionalContacts { get; set; }
|
||||
public WorkOrderFrozenSiteDto? FrozenSite { get; set; }
|
||||
public WorkOrderFrozenCompanyDto? FrozenCompany { get; set; }
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@ namespace SeaHaven.Services.DTOs
|
|||
public int? Severity { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SH-379: replaces the work-order-level POC override (PocName/PocPhone/PocNotes).
|
||||
/// Blank name and phone clear the override so the work order follows the Site's
|
||||
/// live contact again. An edit equal to a live Site contact does not lock (SH-190).
|
||||
/// </summary>
|
||||
public class WorkOrderPocUpdateRequestDto
|
||||
{
|
||||
public string? PocName { get; set; }
|
||||
public string? PocPhone { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>Base64 row version, same concurrency token as the board patch.</summary>
|
||||
public string? WorkOrderVersion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Advanced search / filter query for the board.</summary>
|
||||
public class WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
57
SeaHaven.Services/Helpers/NotificationFeedOrdering.cs
Normal file
57
SeaHaven.Services/Helpers/NotificationFeedOrdering.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class NotificationFeedOrdering
|
||||
{
|
||||
/// <summary>
|
||||
/// Drops empty sections, orders items by severity then recency, and orders sections by
|
||||
/// their highest severity with the fixed reason order breaking ties.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<NotificationSectionDto> Order(
|
||||
IEnumerable<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)> sections)
|
||||
{
|
||||
return sections
|
||||
.Where(section => section.Items.Count > 0)
|
||||
.Select(section =>
|
||||
{
|
||||
var items = section.Items
|
||||
.OrderByDescending(item => NotificationSeverities.Rank(item.Severity))
|
||||
.ThenByDescending(item => item.TriggeredAt)
|
||||
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return new NotificationSectionDto
|
||||
{
|
||||
Reason = section.Reason,
|
||||
Label = NotificationReasons.Labels[section.Reason],
|
||||
Severity = items[0].Severity,
|
||||
Count = section.Count,
|
||||
Items = items
|
||||
};
|
||||
})
|
||||
.OrderByDescending(section => NotificationSeverities.Rank(section.Severity))
|
||||
.ThenBy(section => ReasonIndex(section.Reason))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int ReasonIndex(string reason)
|
||||
{
|
||||
for (var index = 0; index < NotificationReasons.Order.Count; index++)
|
||||
{
|
||||
if (NotificationReasons.Order[index] == reason)
|
||||
return index;
|
||||
}
|
||||
return NotificationReasons.Order.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public static class NotificationBusinessTime
|
||||
{
|
||||
/// <summary>Business-calendar date of <paramref name="utcNow"/> in the dashboard's time zone.</summary>
|
||||
public static DateOnly Today(DateTime utcNow)
|
||||
{
|
||||
var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId);
|
||||
return DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(utcNow, timezone));
|
||||
}
|
||||
}
|
||||
}
|
||||
34
SeaHaven.Services/Helpers/VendorConflictPairs.cs
Normal file
34
SeaHaven.Services/Helpers/VendorConflictPairs.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class VendorConflictPairs
|
||||
{
|
||||
// Group by vendor so cross-vendor intervals are never compared, then sweep the
|
||||
// start-sorted windows within a vendor. Every overlapping pair is still emitted;
|
||||
// output is O(n log n + p) where p is the number of emitted pairs, so the worst
|
||||
// case stays quadratic only when a single vendor has many mutually-overlapping
|
||||
// windows, which is inherent to enumerating all such pairs.
|
||||
public static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> Build(
|
||||
IReadOnlyList<ScheduledDispatchReadModel> dispatches)
|
||||
{
|
||||
foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId))
|
||||
{
|
||||
var sorted = group.OrderBy(dispatch => dispatch.Start).ToList();
|
||||
for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++)
|
||||
{
|
||||
var left = sorted[leftIndex];
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++)
|
||||
{
|
||||
var right = sorted[rightIndex];
|
||||
// Sorted by start: once a window starts at or after left's end, none that follow can overlap it.
|
||||
if (right.Start >= left.End)
|
||||
break;
|
||||
if (left.WorkOrderId != right.WorkOrderId)
|
||||
yield return (left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,14 @@ namespace SeaHaven.Services.Helpers
|
|||
var contact = workOrder.WorkOrderContacts?
|
||||
.OrderBy(item => item.Id)
|
||||
.FirstOrDefault();
|
||||
// SH-379/SH-190: a work order without an override or link freezes the
|
||||
// Site's live primary contact, matching the board display precedence.
|
||||
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(location?.Contacts);
|
||||
var pocName = FirstNotBlank(
|
||||
workOrder.PocName,
|
||||
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName));
|
||||
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber);
|
||||
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName),
|
||||
WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
||||
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber);
|
||||
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes);
|
||||
|
||||
var contacts = new List<WorkOrderFrozenPocContact>();
|
||||
|
|
|
|||
|
|
@ -32,17 +32,18 @@ namespace SeaHaven.Services.Helpers
|
|||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
|
||||
};
|
||||
|
||||
// A browser fills the multipart part's Content-Type from File.type, which mobile
|
||||
// browsers leave empty (or the client sends octet-stream) when the OS cannot
|
||||
// classify a picked file. Only then is the type resolved from the extension; the
|
||||
// category rule, extension pairing, and magic-byte signature still decide.
|
||||
private static readonly HashSet<string> UndeterminedContentTypes =
|
||||
new(StringComparer.OrdinalIgnoreCase) { string.Empty, "application/octet-stream" };
|
||||
|
||||
// The multipart part's Content-Type comes from the browser's File.type, which is
|
||||
// unreliable on mobile: it is left empty or sent as application/octet-stream when the
|
||||
// OS cannot classify a picked file, and is sometimes a foreign-but-plausible video
|
||||
// type the OS attaches to a supported container (e.g. video/3gpp for an .mp4,
|
||||
// video/x-quicktime for a .mov). An allowlisted declared type stays authoritative and
|
||||
// pairs against its own extension; anything else falls back to the extension. The
|
||||
// extension pairing and magic-byte signature below still decide, so this never widens
|
||||
// the accepted set of files.
|
||||
private static string? ResolveContentType(string declaredType, string extension)
|
||||
{
|
||||
if (!UndeterminedContentTypes.Contains(declaredType))
|
||||
return AllowedContentTypes.Contains(declaredType) ? declaredType : null;
|
||||
if (AllowedContentTypes.Contains(declaredType))
|
||||
return declaredType;
|
||||
|
||||
foreach (var (contentType, extensions) in ExtensionsByContentType)
|
||||
{
|
||||
|
|
|
|||
51
SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
Normal file
51
SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379 / SH-190: single resolution for "the Site's live contact" used by the
|
||||
/// manual POC override. The same rules back the board projection fallback, the
|
||||
/// completion snapshot fallback, the create-time baseline, and the
|
||||
/// "changing away from the contact the work order follows" lock comparison.
|
||||
/// </summary>
|
||||
public static class WorkOrderPocSiteContact
|
||||
{
|
||||
/// <summary>
|
||||
/// The Site's primary live contact: first non-deleted contact ordered by
|
||||
/// SiteContactOrder then Id. Null when the site has no live contacts.
|
||||
/// </summary>
|
||||
public static Contacts? ResolvePrimary(IEnumerable<Contacts>? contacts)
|
||||
=> contacts?
|
||||
.Where(c => c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>Display name matching the board projection: trimmed First + Last.</summary>
|
||||
public static string? DisplayName(Contacts? contact)
|
||||
=> contact == null
|
||||
? null
|
||||
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
||||
|
||||
/// <summary>
|
||||
/// SH-190: true when a manual POC (trimmed name + phone) equals the single
|
||||
/// contact the work order currently follows — the linked WorkOrderContacts
|
||||
/// POC, or else the Site primary. Such an edit is not a change away from
|
||||
/// that contact, so it must not lock or store an override. Comparing against
|
||||
/// only the followed contact (rather than any live Site contact) means an
|
||||
/// edit to a different live contact is still stored, since the work order
|
||||
/// only ever displays the one it follows.
|
||||
/// </summary>
|
||||
public static bool Matches(string? name, string? phone, Contacts? contact)
|
||||
{
|
||||
if (contact == null)
|
||||
return false;
|
||||
|
||||
return string.Equals(DisplayName(contact), Normalize(name), StringComparison.Ordinal)
|
||||
&& string.Equals(Normalize(contact.PhoneNumber), Normalize(phone), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
236
SeaHaven.Services/Implementation/NotificationFeedService.cs
Normal file
236
SeaHaven.Services/Implementation/NotificationFeedService.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class NotificationFeedService : INotificationFeedService
|
||||
{
|
||||
/// <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;
|
||||
private readonly IVendorOperationsDataService _vendorData;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public NotificationFeedService(
|
||||
INotificationFeedDataService data,
|
||||
IVendorOperationsDataService vendorData,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_data = data;
|
||||
_vendorData = vendorData;
|
||||
_accountResolver = accountResolver;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
|
||||
var scope = new NotificationFeedScope(accountId, dispatcherId);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
|
||||
if (seesUnassigned)
|
||||
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
|
||||
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
|
||||
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,
|
||||
Sections = NotificationFeedOrdering.Order(sections)
|
||||
};
|
||||
}
|
||||
|
||||
// Dispatchers see their own work orders. Roles that already see every dispatcher's
|
||||
// work on the dashboard see the whole account, including work nobody owns yet.
|
||||
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
|
||||
{
|
||||
if (user.IsInRole("Dispatcher"))
|
||||
{
|
||||
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
return (dispatcherId, false);
|
||||
}
|
||||
|
||||
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
||||
return (null, true);
|
||||
|
||||
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)
|
||||
{
|
||||
var summary = await _data.GetUnassignedSummaryAsync(accountId, cancellationToken);
|
||||
if (summary.Count == 0)
|
||||
return (NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>());
|
||||
|
||||
var item = new NotificationItemDto
|
||||
{
|
||||
Id = NotificationReasons.Unassigned,
|
||||
Reason = NotificationReasons.Unassigned,
|
||||
Severity = NotificationSeverities.High,
|
||||
Title = summary.Count == 1
|
||||
? "1 work order is unassigned"
|
||||
: $"{summary.Count} work orders are unassigned",
|
||||
Count = summary.Count,
|
||||
TriggeredAt = summary.LatestCreatedAt ?? now,
|
||||
Target = new NotificationTargetDto
|
||||
{
|
||||
Kind = NotificationTargetKinds.Queue,
|
||||
Queue = NotificationQueues.Unassigned
|
||||
}
|
||||
};
|
||||
return (NotificationReasons.Unassigned, summary.Count, new[] { item });
|
||||
}
|
||||
|
||||
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> NoVendorAsync(
|
||||
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = await _data.GetNoVendorAsync(scope, now.Add(NoVendorLeadTime), SectionItemLimit, cancellationToken);
|
||||
var items = page.Items.Select(candidate => new NotificationItemDto
|
||||
{
|
||||
Id = $"no-vendor-{candidate.Id}",
|
||||
Reason = NotificationReasons.NoVendor,
|
||||
Severity = NotificationSeverities.Medium,
|
||||
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} starts within 48h with no vendor assigned",
|
||||
Count = 1,
|
||||
TriggeredAt = candidate.ScheduledDate.Subtract(NoVendorLeadTime),
|
||||
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
|
||||
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
||||
}).ToList();
|
||||
return (NotificationReasons.NoVendor, page.Total, items);
|
||||
}
|
||||
|
||||
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> AvetaMissingAsync(
|
||||
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var today = NotificationBusinessTime.Today(now);
|
||||
var page = await _data.GetAvetaMissingAsync(scope, today, SectionItemLimit, cancellationToken);
|
||||
var items = page.Items.Select(candidate => new NotificationItemDto
|
||||
{
|
||||
Id = $"aveta-missing-{candidate.Id}",
|
||||
Reason = NotificationReasons.AvetaMissing,
|
||||
Severity = NotificationSeverities.Medium,
|
||||
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} is missing the Aveta document",
|
||||
Count = 1,
|
||||
// The one-day window opens the day before the scheduled date.
|
||||
TriggeredAt = candidate.ScheduledDate.Date.AddDays(-1),
|
||||
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Extras),
|
||||
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
||||
}).ToList();
|
||||
return (NotificationReasons.AvetaMissing, page.Total, items);
|
||||
}
|
||||
|
||||
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> VendorConflictsAsync(
|
||||
NotificationFeedScope scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatches = await _vendorData.GetActiveScheduledDispatchesAsync(scope.AccountId, cancellationToken);
|
||||
var pairs = VendorConflictPairs.Build(dispatches)
|
||||
.Where(pair => scope.DispatcherId == null
|
||||
|| pair.Left.AssignTo == scope.DispatcherId
|
||||
|| pair.Right.AssignTo == scope.DispatcherId)
|
||||
.ToList();
|
||||
var workOrderCount = pairs
|
||||
.SelectMany(pair => new[] { pair.Left.WorkOrderId, pair.Right.WorkOrderId })
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
// Cap by recency like the other sections: keep the newest overlaps so a section that
|
||||
// exceeds the limit never drops a more recent conflict in favour of an older one.
|
||||
var items = pairs
|
||||
.OrderByDescending(pair => pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start)
|
||||
.ThenByDescending(pair => pair.Left.Id)
|
||||
.ThenByDescending(pair => pair.Right.Id)
|
||||
.Take(SectionItemLimit)
|
||||
.Select(pair => new NotificationItemDto
|
||||
{
|
||||
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
||||
Reason = NotificationReasons.VendorConflict,
|
||||
Severity = NotificationSeverities.Medium,
|
||||
Title = $"{(string.IsNullOrWhiteSpace(pair.Left.VendorName) ? "Vendor" : pair.Left.VendorName)} has overlapping appointments",
|
||||
Count = 2,
|
||||
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
||||
Target = WorkOrderTarget(pair.Left.WorkOrderId, NotificationWorkOrderTabs.Info),
|
||||
WorkOrders = new[]
|
||||
{
|
||||
new NotificationWorkOrderRefDto { Id = pair.Left.WorkOrderId, Number = pair.Left.WorkOrderNumber },
|
||||
new NotificationWorkOrderRefDto { Id = pair.Right.WorkOrderId, Number = pair.Right.WorkOrderNumber }
|
||||
}
|
||||
}).ToList();
|
||||
return (NotificationReasons.VendorConflict, workOrderCount, items);
|
||||
}
|
||||
|
||||
private static NotificationTargetDto WorkOrderTarget(int workOrderId, string tab) => new()
|
||||
{
|
||||
Kind = NotificationTargetKinds.WorkOrder,
|
||||
WorkOrderId = workOrderId,
|
||||
Tab = tab
|
||||
};
|
||||
|
||||
private static string WorkOrderLabel(int id, string? number)
|
||||
=> NotificationActivityItems.WorkOrderLabel(id, number);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,9 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderNumber = r.WorkOrderNumber,
|
||||
WorkOrderSite = r.WorkOrderSiteCode,
|
||||
WorkOrderService = r.WorkOrderService,
|
||||
WorkOrderScheduledDate = r.WorkOrderScheduledDate,
|
||||
WorkOrderDispatcherName = ResolveRequestedByName(null, r.WorkOrderDispatcherFirstName, r.WorkOrderDispatcherLastName),
|
||||
TechnicianName = string.IsNullOrWhiteSpace(r.TechnicianName) ? null : r.TechnicianName.Trim(),
|
||||
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
|
||||
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
||||
CurrentNTE = r.CurrentNTE,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using Data.SeaHavenIndustries.Enums;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
|
|
@ -433,32 +434,9 @@ namespace SeaHaven.Services.Implementation
|
|||
.ToList();
|
||||
}
|
||||
|
||||
// Group by vendor so cross-vendor intervals are never compared, then sweep the
|
||||
// start-sorted windows within a vendor. Every overlapping pair is still emitted;
|
||||
// output is O(n log n + p) where p is the number of emitted pairs, so the worst
|
||||
// case stays quadratic only when a single vendor has many mutually-overlapping
|
||||
// windows, which is inherent to enumerating all such pairs.
|
||||
private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs(
|
||||
IReadOnlyList<ScheduledDispatchReadModel> dispatches)
|
||||
{
|
||||
foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId))
|
||||
{
|
||||
var sorted = group.OrderBy(dispatch => dispatch.Start).ToList();
|
||||
for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++)
|
||||
{
|
||||
var left = sorted[leftIndex];
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++)
|
||||
{
|
||||
var right = sorted[rightIndex];
|
||||
// Sorted by start: once a window starts at or after left's end, none that follow can overlap it.
|
||||
if (right.Start >= left.End)
|
||||
break;
|
||||
if (left.WorkOrderId != right.WorkOrderId)
|
||||
yield return (left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
=> VendorConflictPairs.Build(dispatches);
|
||||
|
||||
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
|
||||
=> leftStart < rightEnd && rightStart < leftEnd;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly IWorkOrderPocDataService _pocData;
|
||||
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||
|
||||
public WorkOrderBoardCreateService(
|
||||
|
|
@ -29,6 +30,7 @@ namespace SeaHaven.Services.Implementation
|
|||
IWorkOrderAuditService auditService,
|
||||
IWorkOrderBoardCreateValidation validator,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
IWorkOrderPocDataService pocData,
|
||||
IServicesRegistryService? servicesRegistryService = null)
|
||||
{
|
||||
_boardDataService = boardDataService;
|
||||
|
|
@ -37,6 +39,7 @@ namespace SeaHaven.Services.Implementation
|
|||
_auditService = auditService;
|
||||
_validator = validator;
|
||||
_accountResolver = accountResolver;
|
||||
_pocData = pocData;
|
||||
_servicesRegistryService = servicesRegistryService;
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +67,25 @@ namespace SeaHaven.Services.Implementation
|
|||
var pocName = TrimOrNull(request.PocName);
|
||||
var pocPhone = TrimOrNull(request.PocPhone);
|
||||
var pocNotes = TrimOrNull(request.PocNotes);
|
||||
// SH-190 AC3: a create-time POC equal to the single contact the work
|
||||
// order will follow is not an override. Store nothing so the work order
|
||||
// keeps following that contact until a dispatcher edits away from it.
|
||||
// The followed contact is the linked POC (PocContactId) when one is
|
||||
// supplied, otherwise the Site primary. Comparing against the followed
|
||||
// contact rather than any live Site contact means a create-time POC that
|
||||
// matches a *different* Site contact is stored, since the board only
|
||||
// shows the one the work order follows. A supplied PocContactId that is
|
||||
// not among the Site's live contacts leaves no follow target, so the
|
||||
// typed POC is stored.
|
||||
var siteContacts = await _pocData.GetSiteContactsAsync(request.LocationId, cancellationToken);
|
||||
var followContact = request.PocContactId.HasValue
|
||||
? siteContacts.FirstOrDefault(c => c.Id == request.PocContactId.Value)
|
||||
: WorkOrderPocSiteContact.ResolvePrimary(siteContacts);
|
||||
if (WorkOrderPocSiteContact.Matches(pocName, pocPhone, followContact))
|
||||
{
|
||||
pocName = null;
|
||||
pocPhone = null;
|
||||
}
|
||||
var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts);
|
||||
var techPhone = TrimOrNull(request.TechPhone);
|
||||
var vendorNotes = TrimOrNull(request.VendorNotes);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ namespace SeaHaven.Services.Implementation
|
|||
PocName = row.PocName,
|
||||
PocPhone = row.PocPhone,
|
||||
PocNotes = row.PocNotes,
|
||||
PocCustomized = !string.IsNullOrWhiteSpace(row.WoPocName) || !string.IsNullOrWhiteSpace(row.WoPocPhone),
|
||||
FrozenSite = MapFrozenSite(row.FrozenSite),
|
||||
FrozenCompany = MapFrozenCompany(row.FrozenCompany),
|
||||
FrozenPoc = MapFrozenPoc(row.FrozenPoc),
|
||||
|
|
|
|||
176
SeaHaven.Services/Implementation/WorkOrderPocService.cs
Normal file
176
SeaHaven.Services/Implementation/WorkOrderPocService.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379: persists a manual POC override on an existing work order. The manual
|
||||
/// value is stored in the WO-level PocName/PocPhone/PocNotes fields, staged as
|
||||
/// FieldChanged audit entries (which also write field locks so later syncs never
|
||||
/// overwrite a manual POC, SH-190 AC2), and captured by the completion freeze.
|
||||
/// An edit equal to the contact the work order currently follows (its linked
|
||||
/// WorkOrderContacts POC, or else the Site primary) stores no override, so a
|
||||
/// never-overridden work order keeps following the Site (SH-190 AC3).
|
||||
/// </summary>
|
||||
public class WorkOrderPocService : IWorkOrderPocService
|
||||
{
|
||||
private readonly IWorkOrderPocDataService _pocData;
|
||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderPocService(
|
||||
IWorkOrderPocDataService pocData,
|
||||
IWorkOrderBoardMutationDataService mutationData,
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderAuditService auditService,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_pocData = pocData;
|
||||
_mutationData = mutationData;
|
||||
_boardService = boardService;
|
||||
_auditService = auditService;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> UpdatePocAsync(
|
||||
int workOrderId,
|
||||
WorkOrderPocUpdateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
|
||||
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
|
||||
if (workOrderVersion == null)
|
||||
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
||||
|
||||
ValidateLengths(request);
|
||||
|
||||
await _mutationData.ExecuteTransactionalAsync(async ct =>
|
||||
{
|
||||
var workOrder = await _pocData.GetTrackedWorkOrderAsync(workOrderId, accountId, ct);
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
|
||||
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
|
||||
|
||||
if (WorkOrderBoardMutationRules.IsFullyLocked(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
|
||||
if (WorkOrderBoardMutationRules.IsCoreLocked(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||
|
||||
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(workOrder.Locations?.Contacts);
|
||||
var linkedContact = workOrder.WorkOrderContacts?
|
||||
.OrderBy(item => item.Id)
|
||||
.FirstOrDefault()?.POC;
|
||||
|
||||
var newName = TrimOrNull(request.PocName);
|
||||
var newPhone = TrimOrNull(request.PocPhone);
|
||||
var newNotes = TrimOrNull(request.PocNotes);
|
||||
|
||||
// SH-190: changing away from the contact the work order follows
|
||||
// locks the POC. An edit that equals that single contact — the
|
||||
// linked WorkOrderContacts POC, or else the Site primary — (and
|
||||
// blanking both fields) stores no override, so the work order keeps
|
||||
// following it. Comparing against the followed contact rather than
|
||||
// any live Site contact means editing to a *different* Site contact
|
||||
// is stored as an override, since the board only ever shows the one
|
||||
// the work order follows.
|
||||
var followContact = linkedContact ?? sitePrimary;
|
||||
var overrideName = newName;
|
||||
var overridePhone = newPhone;
|
||||
if ((overrideName != null || overridePhone != null)
|
||||
&& WorkOrderPocSiteContact.Matches(overrideName, overridePhone, followContact))
|
||||
{
|
||||
overrideName = null;
|
||||
overridePhone = null;
|
||||
}
|
||||
|
||||
var oldEffectiveName = FirstNotBlank(workOrder.PocName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
||||
var oldEffectivePhone = FirstNotBlank(workOrder.PocPhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
|
||||
var newEffectiveName = FirstNotBlank(overrideName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
||||
var newEffectivePhone = FirstNotBlank(overridePhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
|
||||
|
||||
var storedChanged = !string.Equals(workOrder.PocName, overrideName, StringComparison.Ordinal)
|
||||
|| !string.Equals(workOrder.PocPhone, overridePhone, StringComparison.Ordinal)
|
||||
|| !string.Equals(workOrder.PocNotes, newNotes, StringComparison.Ordinal);
|
||||
|
||||
var oldNotes = workOrder.PocNotes;
|
||||
workOrder.PocName = overrideName;
|
||||
workOrder.PocPhone = overridePhone;
|
||||
workOrder.PocNotes = newNotes;
|
||||
|
||||
if (!storedChanged)
|
||||
return;
|
||||
|
||||
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
||||
|
||||
// Audit effective old -> new values with the same field names create
|
||||
// uses, so the trail reads consistently across create and update.
|
||||
if (!string.Equals(oldEffectiveName, newEffectiveName, StringComparison.Ordinal))
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "PocName", oldEffectiveName, newEffectiveName, actorId);
|
||||
if (!string.Equals(oldEffectivePhone, newEffectivePhone, StringComparison.Ordinal))
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "PocPhone", oldEffectivePhone, newEffectivePhone, actorId);
|
||||
if (!string.Equals(TrimOrNull(oldNotes), newNotes, StringComparison.Ordinal))
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "PocNotes", TrimOrNull(oldNotes), newNotes, actorId);
|
||||
|
||||
var outcome = await _mutationData.SaveAsync(ct);
|
||||
if (outcome == BoardSaveOutcome.ConcurrencyConflict)
|
||||
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
|
||||
}, CancellationToken.None);
|
||||
|
||||
var row = await LoadBoardRowAsync(workOrderId, user);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId, ClaimsPrincipal user)
|
||||
=> await _boardService.GetBoardRowAsync(workOrderId, user);
|
||||
|
||||
private static void ValidateLengths(WorkOrderPocUpdateRequestDto request)
|
||||
{
|
||||
if (request.PocName?.Length > 200)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "pocName must be at most 200 characters.");
|
||||
if (request.PocPhone?.Length > 50)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "pocPhone must be at most 50 characters.");
|
||||
if (request.PocNotes?.Length > 2000)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "pocNotes must be at most 2000 characters.");
|
||||
}
|
||||
|
||||
private static string? ContactDisplayName(Contacts? contact)
|
||||
=> contact == null
|
||||
? null
|
||||
: TrimOrNull(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
||||
|
||||
private static string? FirstNotBlank(params string?[] values)
|
||||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static byte[]? ParseRowVersion(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
|
||||
=> current != null && current.AsSpan().SequenceEqual(expected);
|
||||
}
|
||||
}
|
||||
14
SeaHaven.Services/Interfaces/INotificationFeedService.cs
Normal file
14
SeaHaven.Services/Interfaces/INotificationFeedService.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Notification Center feed for the signed-in user: live conditions derived from current
|
||||
/// work-order state at read time, account-scoped from claims and grouped by reason.
|
||||
/// </summary>
|
||||
public interface INotificationFeedService
|
||||
{
|
||||
Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
18
SeaHaven.Services/Interfaces/IWorkOrderPocService.cs
Normal file
18
SeaHaven.Services/Interfaces/IWorkOrderPocService.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379: persists a manual POC override on an existing work order with audit
|
||||
/// entries, and applies the SH-190 follow-site / lock semantics.
|
||||
/// </summary>
|
||||
public interface IWorkOrderPocService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> UpdatePocAsync(
|
||||
int workOrderId,
|
||||
WorkOrderPocUpdateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,91 @@ public sealed class UpliftQueueReadRelationalTests
|
|||
Assert.True(item.WorkOrderClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_ResolvesDetailModalContext_PerAccountWorkOrder()
|
||||
{
|
||||
// SH-209: dispatcher (work-order assignee navigation), technician (vendor
|
||||
// contact) and scheduled date must translate to SQL through the effective
|
||||
// work-order subquery, and each row resolves them from its own account's
|
||||
// work order only.
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new SqliteUpliftTestDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
var accountA = new Accounts { Name = "Account A" };
|
||||
var accountB = new Accounts { Name = "Account B" };
|
||||
var dispatcherA = new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" };
|
||||
var vendorA = new Vendor { CompanyName = "Vendor A", ContactName = "Tech A", IsActive = true };
|
||||
var vendorB = new Vendor { CompanyName = "Vendor B", ContactName = "Tech B", IsActive = true };
|
||||
context.AddRange(accountA, accountB, dispatcherA, vendorA, vendorB);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var workOrderA = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-A",
|
||||
WorkerOrderTitle = "Repair",
|
||||
AccountId = accountA.Id,
|
||||
AssignTo = dispatcherA.Id,
|
||||
ScheduledDate = new DateTime(2026, 5, 1, 8, 0, 0)
|
||||
};
|
||||
var workOrderB = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-B",
|
||||
WorkerOrderTitle = "Repair",
|
||||
AccountId = accountB.Id
|
||||
};
|
||||
context.AddRange(workOrderA, workOrderB);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dispatchA = new Dispatch { VendorId = vendorA.Id, WorkOrderId = workOrderA.Id, DispatchNumber = "DIS-A", Status = "Scheduled" };
|
||||
var dispatchB = new Dispatch { VendorId = vendorB.Id, WorkOrderId = workOrderB.Id, DispatchNumber = "DIS-B", Status = "Scheduled" };
|
||||
context.Dispatches.AddRange(dispatchA, dispatchB);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 4, 1),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 100m,
|
||||
NotificationStatus = "Pending"
|
||||
},
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchB.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 4, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 200m,
|
||||
NotificationStatus = "Pending"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var (total, items) = await data.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, total);
|
||||
var rowA = Assert.Single(items, i => i.WorkOrderId == workOrderA.Id);
|
||||
Assert.Equal("Ann", rowA.WorkOrderDispatcherFirstName);
|
||||
Assert.Equal("Alpha", rowA.WorkOrderDispatcherLastName);
|
||||
Assert.Equal("Tech A", rowA.TechnicianName);
|
||||
Assert.Equal(new DateTime(2026, 5, 1, 8, 0, 0), rowA.WorkOrderScheduledDate);
|
||||
var rowB = Assert.Single(items, i => i.WorkOrderId == workOrderB.Id);
|
||||
Assert.Null(rowB.WorkOrderDispatcherFirstName);
|
||||
Assert.Null(rowB.WorkOrderDispatcherLastName);
|
||||
Assert.Equal("Tech B", rowB.TechnicianName);
|
||||
Assert.Null(rowB.WorkOrderScheduledDate);
|
||||
}
|
||||
|
||||
private sealed class SqliteUpliftTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public class WorkOrderAccountScopeTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
return new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -232,7 +232,7 @@ public class WorkOrderAccountScopeTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var create = new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await create.CreateAsync(
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
|
|
@ -111,7 +112,8 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var validator = new WorkOrderBoardCreateValidation();
|
||||
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver);
|
||||
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver, new WorkOrderPocDataService(context));
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
|
|
@ -81,7 +82,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
|
|
|
|||
|
|
@ -2084,11 +2084,40 @@ public class WorkOrderMediaFileRulesTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_DeclaredMimeMismatchingExtension_StillRejected()
|
||||
public void IsAllowed_AllowlistedDeclaredMimeMismatchingExtension_StillRejected()
|
||||
{
|
||||
// A concrete declared type is authoritative; only an undetermined one falls back to the extension.
|
||||
// An allowlisted declared type stays authoritative and pairs against its own
|
||||
// extension, so declaring video/mp4 for a .mov (or the reverse) is still a spoof.
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.mp4", "video/quicktime")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// SH-381: mobile browsers attach a foreign-but-plausible type to a supported container.
|
||||
// The extension plus magic bytes must decide, not the unreliable declared MIME.
|
||||
[InlineData("clip.mp4", "video/3gpp")]
|
||||
[InlineData("VID_0001.MP4", "video/3gpp")]
|
||||
[InlineData("IMG_1587.MOV", "video/x-quicktime")]
|
||||
[InlineData("IMG_1587.mov", "video/mpeg")]
|
||||
[InlineData("photo.jpg", "image/heic")]
|
||||
public void IsAllowed_ForeignDeclaredMime_ResolvesFromExtensionAndSignature(
|
||||
string fileName,
|
||||
string contentType)
|
||||
{
|
||||
Assert.True(WorkOrderMediaFileRules.IsAllowed(
|
||||
FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_ForeignDeclaredMime_StillRequiresMatchingSignatureAndExtension()
|
||||
{
|
||||
// Falling back to the extension does not weaken the gate: the bytes must still match
|
||||
// the resolved type, the extension must still be supported, and a resolved document
|
||||
// stays out of photo/video categories.
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mp4", "video/3gpp")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.exe", "video/3gpp")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(
|
||||
FormFile(RealPdf, "report.pdf", "application/x-unknown"), WorkOrderMediaCategory.Before));
|
||||
}
|
||||
|
||||
private static byte[] BytesFor(string fileName)
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
|
|
|
|||
641
SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
Normal file
641
SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderPocServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ManualOverridePersistsIsAuditedAndLocks()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", "Call first", workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Equal("Manual POC", persisted.PocName);
|
||||
Assert.Equal("312-555-0001", persisted.PocPhone);
|
||||
Assert.Equal("Call first", persisted.PocNotes);
|
||||
Assert.Equal("Manual POC", row.PocName);
|
||||
Assert.Equal("312-555-0001", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
|
||||
Assert.Equal("Alice Site", auditName.OldValue);
|
||||
Assert.Equal("Manual POC", auditName.NewValue);
|
||||
Assert.Equal("FieldChanged", auditName.Action);
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualOverrideSurvivesCompleteAndLaterSiteChanges()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var updateService = CreateUpdateService(context);
|
||||
var completed = await updateService.PatchFieldAsync(
|
||||
workOrder.Id,
|
||||
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
|
||||
"actor-1");
|
||||
Assert.Equal("Manual POC", completed.PocName);
|
||||
|
||||
var siteContact = await context.Contacts.SingleAsync(c => c.FirstName == "Alice");
|
||||
siteContact.FirstName = "Changed";
|
||||
siteContact.PhoneNumber = "999-555-0100";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var row = await CreateBoardService(context).GetBoardRowAsync(
|
||||
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Manual POC", row!.PocName);
|
||||
Assert.Equal("312-555-0001", row.PocPhone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteContactUpdateDoesNotOverwriteManualOverride()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
var siteContact = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
siteContact.FirstName = "Rotated";
|
||||
siteContact.PhoneNumber = "999-555-0100";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var row = await CreateBoardService(context).GetBoardRowAsync(
|
||||
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Manual POC", row!.PocName);
|
||||
Assert.Equal("312-555-0001", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnlockedWorkOrderFollowsSiteContact()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
var primary = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
|
||||
await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardService = CreateBoardService(context);
|
||||
var before = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Alice Site", before!.PocName);
|
||||
Assert.Equal("312-555-0100", before.PocPhone);
|
||||
Assert.False(before.PocCustomized);
|
||||
|
||||
primary.PhoneNumber = "312-555-0999";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var after = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Alice Site", after!.PocName);
|
||||
Assert.Equal("312-555-0999", after.PocPhone);
|
||||
Assert.False(after.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditEqualToSiteContactDoesNotLock()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Alice Site", "312-555-0100", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Null(persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.Equal("312-555-0100", row.PocPhone);
|
||||
Assert.False(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.Where(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName")
|
||||
.OrderByDescending(a => a.Id)
|
||||
.FirstAsync();
|
||||
Assert.Equal("Manual POC", auditName.OldValue);
|
||||
Assert.Equal("Alice Site", auditName.NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearingBothFieldsFollowsSiteAgain()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", "keep notes", workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc(null, null, null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Null(persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.False(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CrossTenantUpdateIsRejected()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context, accountId: 2);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(accountId: 1),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingAccountScopeIsForbidden()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.MissingScope(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("Forbidden", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompletedWorkOrderIsReadOnly()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Completed;
|
||||
workOrder.CompletedDate = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("ReadOnly", ex.Code);
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledWorkOrderIsReadOnly()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleVersionThrowsConflict()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var stale = new WorkOrderPocUpdateRequestDto
|
||||
{
|
||||
PocName = "Manual POC",
|
||||
PocPhone = "312-555-0001",
|
||||
WorkOrderVersion = Convert.ToBase64String(new byte[] { 9, 9, 9, 9 })
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
|
||||
service.UpdatePocAsync(workOrder.Id, stale, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompletionFreezesSiteContactWhenNoOverrideOrLink()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var updateService = CreateUpdateService(context);
|
||||
var completed = await updateService.PatchFieldAsync(
|
||||
workOrder.Id,
|
||||
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
|
||||
"actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
|
||||
Assert.Equal("Alice Site", completed.PocName);
|
||||
Assert.Equal("312-555-0100", completed.PocPhone);
|
||||
|
||||
var contact = await context.Contacts.SingleAsync();
|
||||
contact.PhoneNumber = "999-555-0100";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var row = await CreateBoardService(context).GetBoardRowAsync(
|
||||
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Alice Site", row!.PocName);
|
||||
Assert.Equal("312-555-0100", row.PocPhone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardCreateWithPocEqualToSiteContactFollowsSite()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Alice",
|
||||
LastName = "Site",
|
||||
PhoneNumber = "312-555-0100",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateCreateService(context);
|
||||
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
PocName = "Alice Site",
|
||||
PocPhone = "312-555-0100"
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Null(persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.False(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardCreateWithPocAwayFromSiteContactIsStored()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Alice",
|
||||
LastName = "Site",
|
||||
PhoneNumber = "312-555-0100",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateCreateService(context);
|
||||
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
PocName = "Manual POC",
|
||||
PocPhone = "312-555-0001"
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||
Assert.Equal("Manual POC", persisted.PocName);
|
||||
Assert.Equal("312-555-0001", persisted.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditToSecondSiteContactStoresOverride()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
|
||||
await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// The WO follows the Site primary (Alice). Editing to the second live Site
|
||||
// contact (Bob) is a change away from what the board shows and must be
|
||||
// stored and locked, not silently cleared back to Alice.
|
||||
var service = CreatePocService(context);
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Bob Backup", "312-555-0200", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Equal("Bob Backup", persisted.PocName);
|
||||
Assert.Equal("312-555-0200", persisted.PocPhone);
|
||||
Assert.Equal("Bob Backup", row.PocName);
|
||||
Assert.Equal("312-555-0200", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
|
||||
Assert.Equal("Alice Site", auditName.OldValue);
|
||||
Assert.Equal("Bob Backup", auditName.NewValue);
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditToSitePrimaryWhenLinkedContactDiffersStoresOverride()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
|
||||
var linked = new Contacts
|
||||
{
|
||||
FirstName = "Carol",
|
||||
LastName = "Linked",
|
||||
PhoneNumber = "312-555-0300"
|
||||
};
|
||||
context.Contacts.Add(linked);
|
||||
await context.SaveChangesAsync();
|
||||
context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||
{
|
||||
WorkorderId = workOrder.Id,
|
||||
ContactId = linked.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// The WO follows its linked contact (Carol). Typing the Site's primary
|
||||
// (Alice) is a change away from what the board shows and must be stored,
|
||||
// not cleared back to Carol.
|
||||
var service = CreatePocService(context);
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Alice Site", "312-555-0100", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Equal("Alice Site", persisted.PocName);
|
||||
Assert.Equal("312-555-0100", persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.Equal("312-555-0100", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
|
||||
Assert.Equal("Carol Linked", auditName.OldValue);
|
||||
Assert.Equal("Alice Site", auditName.NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardCreateWithPocEqualToSecondSiteContactIsStored()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Alice",
|
||||
LastName = "Site",
|
||||
PhoneNumber = "312-555-0100",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 0
|
||||
});
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Bob",
|
||||
LastName = "Backup",
|
||||
PhoneNumber = "312-555-0200",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 1
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Create-time POC equal to the second Site contact (Bob) is not the contact
|
||||
// the WO would follow (the primary, Alice), so it must be stored.
|
||||
var service = CreateCreateService(context);
|
||||
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
PocName = "Bob Backup",
|
||||
PocPhone = "312-555-0200"
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||
Assert.Equal("Bob Backup", persisted.PocName);
|
||||
Assert.Equal("312-555-0200", persisted.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task SeedScopeAsync(ApplicationDbContext context)
|
||||
{
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static WorkOrder NewWorkOrder(ApplicationDbContext context, int accountId = 1)
|
||||
{
|
||||
var location = context.Locations.Local.Single(l => l.Id == 1);
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000012345",
|
||||
SiteCode = "BK5",
|
||||
LocationId = location.Id,
|
||||
Locations = location,
|
||||
AccountId = accountId,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = Version()
|
||||
};
|
||||
context.workOrders.Add(workOrder);
|
||||
return workOrder;
|
||||
}
|
||||
|
||||
private static async Task<Contacts> SeedSiteContactAsync(
|
||||
ApplicationDbContext context,
|
||||
string firstName,
|
||||
string lastName,
|
||||
string phone,
|
||||
int order = 0)
|
||||
{
|
||||
var contact = new Contacts
|
||||
{
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
PhoneNumber = phone,
|
||||
LocationId = 1,
|
||||
SiteContactOrder = order
|
||||
};
|
||||
context.Contacts.Add(contact);
|
||||
await context.SaveChangesAsync();
|
||||
return contact;
|
||||
}
|
||||
|
||||
private static WorkOrderPocService CreatePocService(ApplicationDbContext context)
|
||||
=> new(
|
||||
new WorkOrderPocDataService(context),
|
||||
new WorkOrderBoardMutationDataService(context),
|
||||
CreateBoardService(context),
|
||||
new WorkOrderAuditService(
|
||||
new WorkOrderAuditDataService(context),
|
||||
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
private static WorkOrderBoardService CreateBoardService(ApplicationDbContext context)
|
||||
=> new(
|
||||
new WorkOrderBoardDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context)
|
||||
{
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
return new WorkOrderBoardUpdateService(
|
||||
new WorkOrderBoardDataService(context),
|
||||
new WorkOrderBoardMutationDataService(context),
|
||||
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
||||
}
|
||||
|
||||
private static WorkOrderBoardCreateService CreateCreateService(ApplicationDbContext context)
|
||||
=> new(
|
||||
new WorkOrderBoardDataService(context),
|
||||
new WorkOrderBoardMutationDataService(context),
|
||||
CreateBoardService(context),
|
||||
new WorkOrderAuditService(
|
||||
new WorkOrderAuditDataService(context),
|
||||
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new WorkOrderPocDataService(context));
|
||||
|
||||
private static WorkOrderPocUpdateRequestDto Poc(
|
||||
string? name,
|
||||
string? phone,
|
||||
string? notes,
|
||||
WorkOrder workOrder)
|
||||
=> new()
|
||||
{
|
||||
PocName = name,
|
||||
PocPhone = phone,
|
||||
PocNotes = notes,
|
||||
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
|
||||
};
|
||||
|
||||
private static WorkOrderBoardPatchRequestDto Patch(
|
||||
string field,
|
||||
string value,
|
||||
WorkOrder workOrder)
|
||||
=> new()
|
||||
{
|
||||
Field = field,
|
||||
Value = value,
|
||||
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
|
||||
};
|
||||
|
||||
private static byte[] Version() => new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||
}
|
||||
52
TODO.md
52
TODO.md
|
|
@ -1,52 +0,0 @@
|
|||
# SHOC — TODO
|
||||
|
||||
## Vendor Portal (Next Priority)
|
||||
- [ ] Change dispatch email Accept button to "View Work Order" linking to vendor portal
|
||||
- [ ] Vendor web portal — public pages authenticated by dispatch token
|
||||
- [ ] Vendor can view their dispatches, accept, update status, complete sign-offs, fill checklists
|
||||
- [ ] No login required — token-based access from email link
|
||||
- [ ] Per-WO template selection in multi-WO dispatch
|
||||
|
||||
## Dispatch & Vendor Communication
|
||||
- [x] Dispatch detail modal — view/edit status, dates, NTE, description
|
||||
- [x] Dispatch number — auto-generated DSP-00001
|
||||
- [x] DispatchId on Comments — tie vendor comments to specific dispatches
|
||||
- [x] Vendor activity feed per dispatch
|
||||
- [x] Internal user → vendor email
|
||||
- [x] Cancel dispatch button
|
||||
- [x] Checklist items with template support
|
||||
- [x] Two-party sign-offs (Customer + Vendor)
|
||||
- [x] Dispatcher verification gate
|
||||
- [x] Vendor accept via email link
|
||||
- [x] Time tracking / wait indicators
|
||||
- [x] Multi-WO dispatch
|
||||
- [ ] **Front API integration** — replace SendGrid/SES with Front inbox (vendors@seahaven.com)
|
||||
- [ ] Add ability for dispatcher to assign a template checklist after initial dispatch
|
||||
- [ ] Resend dispatch email button
|
||||
|
||||
## Work Order List
|
||||
- [ ] Pinned Actions column — CSS grid doesn't support sticky horizontal scroll, revisit
|
||||
|
||||
## Pages Not Yet Built
|
||||
- [ ] `/profile` — My Profile page (linked from user dropdown)
|
||||
- [ ] `/settings` — Account Settings page (linked from user dropdown)
|
||||
- [ ] `/change-password` — Change Password page (linked from user dropdown)
|
||||
|
||||
## Controllers Not Yet Built
|
||||
- [ ] `CalendarController` — backend CRUD for calendar/events (frontend pages exist)
|
||||
|
||||
## Dashboard
|
||||
- [ ] Priority Analysis table — needs backend endpoint to calculate age buckets by severity
|
||||
- [ ] Invoice Analysis table — placeholder data, no backend yet
|
||||
|
||||
## Data & Integration
|
||||
- [ ] Customer NTE — wire to PO database (placeholder on WO view page)
|
||||
- [ ] Vendor PO detailed format — line items, billing address, payment terms, expiration, T&C
|
||||
- [ ] PDF generation for Vendor PO — QuestPDF or similar
|
||||
- [ ] Address-based distance calculation — upgrade from zip-to-zip to geocoded addresses
|
||||
- [ ] Backfill location zip codes from address strings during sync
|
||||
|
||||
## Deployment
|
||||
- [ ] When SHOC deploys, switch workorder-ingest Lambda from DynamoDB to direct SHOC API calls
|
||||
- [ ] Retire DynamoDB sync bridge
|
||||
- [ ] GitHub branch protection — requires paid plan for private repos
|
||||
|
|
@ -41,6 +41,16 @@ if ! git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Diff the change set from the merge base, not from the base tip. A two-dot
|
||||
# diff against a moving base reports everything the base gained after the
|
||||
# branch point as if this change reverted it, so a branch behind main that
|
||||
# touches C# would fail G13 whenever main had merged Terraform in the meantime.
|
||||
# The merge queue no longer requires branches to be current, so this matters.
|
||||
DIFF_BASE="$(git merge-base "${BASE_REF}" "${HEAD_REF}")" || {
|
||||
bad "G3: no merge base between '${BASE_REF}' and '${HEAD_REF}'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "G1: restore"
|
||||
"$DOTNET" restore "$SOLUTION"
|
||||
ok "G1: restore"
|
||||
|
|
@ -52,16 +62,16 @@ log "G2: architecture boundary tests (dependency direction)"
|
|||
--nologo
|
||||
ok "G2: ArchitectureTests"
|
||||
|
||||
log "G3: changed-file formatting (${BASE_REF}..${HEAD_REF})"
|
||||
log "G3: changed-file formatting (${BASE_REF}...${HEAD_REF}, merge base ${DIFF_BASE:0:7})"
|
||||
changed_cs=()
|
||||
while IFS= read -r f; do
|
||||
changed_cs+=("$f")
|
||||
done < <(
|
||||
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}" -- '*.cs'
|
||||
git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}" -- '*.cs'
|
||||
)
|
||||
|
||||
if (( ${#changed_cs[@]} == 0 )); then
|
||||
printf '\033[33mSKIP\033[0m G3: no changed C# files between %s..%s\n' "${BASE_REF}" "${HEAD_REF}"
|
||||
printf '\033[33mSKIP\033[0m G3: no changed C# files between %s...%s\n' "${BASE_REF}" "${HEAD_REF}"
|
||||
else
|
||||
printf ' checking %d changed C# file(s)\n' "${#changed_cs[@]}"
|
||||
"$DOTNET" format "$SOLUTION" \
|
||||
|
|
@ -89,9 +99,9 @@ python3 scripts/test_require_commit_checks.py
|
|||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
ok "Release promotion scripts"
|
||||
|
||||
log "G13: application and Terraform isolation (${BASE_REF}..${HEAD_REF})"
|
||||
log "G13: application and Terraform isolation (${BASE_REF}...${HEAD_REF}, merge base ${DIFF_BASE:0:7})"
|
||||
python3 scripts/check_app_terraform_isolation.py < <(
|
||||
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}"
|
||||
git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}"
|
||||
)
|
||||
ok "G13: application and Terraform isolation"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue