Compare commits

..

No commits in common. "752f5fdea1c987b06ad43f53d01715bb07b6d656" and "1888b66940d03d82ec7326f37bf57ebb9196107f" have entirely different histories.

57 changed files with 294 additions and 3678 deletions

View file

@ -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
View file

@ -1,2 +1,63 @@
# Normalize line endings on checkout and checkin.
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* 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

View file

@ -1,22 +0,0 @@
<!--
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
View file

@ -1,22 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["nuget", "github-actions", "terraform", "custom.regex"],
"schedule": ["before 6am every weekday"],
"enabledManagers": ["nuget", "github-actions", "terraform"],
"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": [
@ -26,10 +12,8 @@
"dependencyDashboardApproval": true
},
{
"description": [
"Group non-major nuget updates, including the dotnet-ef pin in the packaging script"
],
"matchManagers": ["nuget", "custom.regex"],
"description": ["Group non-major nuget updates"],
"matchManagers": ["nuget"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "nuget minor and patch"
},
@ -51,18 +35,6 @@
"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\\./"],

View file

@ -6,7 +6,9 @@ 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).
1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical;
supersedes the older `BACKEND_ARCHITECTURE.md`, which is retained only as
historical reference).
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.

View file

@ -1,8 +1,9 @@
# Architecture and Code Quality (canonical)
> **Status: canonical.** This document owns the *meaning* of the backend's
> architecture and code-quality rules. It replaced the earlier
> `BACKEND_ARCHITECTURE.md`, which now lives only in git history.
> 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.
>
> Companion documents:
> - `QUALITY_GATES.md` — *how* each rule is enforced (commands + CI mapping).

View file

@ -1,452 +0,0 @@
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>();
}
}

View file

@ -1,588 +0,0 @@
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();
}
}

View file

@ -282,92 +282,6 @@ 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()
{

View file

@ -23,7 +23,6 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
advancedSearch);
controller.ControllerContext = new ControllerContext

View file

@ -71,7 +71,6 @@ 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",

View file

@ -1,37 +0,0 @@
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
});
}
}
}
}

View file

@ -24,7 +24,6 @@ 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(
@ -32,14 +31,12 @@ 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;
}
@ -168,56 +165,6 @@ 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,

106
BACKEND_ARCHITECTURE.md Normal file
View file

@ -0,0 +1,106 @@
# 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.

View file

@ -85,12 +85,6 @@ 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
View file

@ -1,115 +0,0 @@
# SHOC Backend (`shoc-backend`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-backend/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Sea-Haven-Industries/shoc-backend/actions/workflows/ci.yml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-backend/actions/workflows/deploy.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-backend/actions/workflows/deploy.yaml)
![.NET 8](https://img.shields.io/badge/.NET-8.0-512BD4?logo=dotnet&logoColor=white)
![SQL Server](https://img.shields.io/badge/SQL%20Server-CC2927?logo=microsoftsqlserver&logoColor=white)
![Terraform](https://img.shields.io/badge/Terraform-844FBA?logo=terraform&logoColor=white)
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.

View file

@ -130,11 +130,6 @@ 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

View file

@ -52,20 +52,6 @@ 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,
@ -130,11 +116,11 @@ namespace SeaHaven.DataServices.Helpers
: null;
var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault();
var pocName = primaryFrozenPoc?.Name
?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name);
?? (!string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name);
var pocPhone = primaryFrozenPoc?.Phone
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
?? (!string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber);
var pocNotes = frozenPoc?.Notes
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
?? (!string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes);
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
@ -202,15 +188,10 @@ namespace SeaHaven.DataServices.Helpers
w.MediaCount,
frozenSite,
frozenCompany,
frozenPoc,
w.WoPocName,
w.WoPocPhone);
frozenPoc);
}).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))

View file

@ -1,226 +0,0 @@
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 };
}
}
}

View file

@ -79,18 +79,6 @@ 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

View file

@ -23,15 +23,12 @@ namespace SeaHaven.DataServices.Implementation
{
var directlyAssignedIds = await _context.Dispatches
.AsNoTracking()
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "")
&& (d.IsDeleted == null || d.IsDeleted == false)
&& d.WorkOrderId.HasValue)
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && 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);
@ -59,23 +56,14 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(cancellationToken);
}
public Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken)
=> GetActiveScheduledDispatchesAsync(null, cancellationToken);
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(
int? accountId, CancellationToken cancellationToken)
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken)
{
var directDispatches = _context.Dispatches.AsNoTracking()
var direct = _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)));
if (accountId is int directAccountId)
directDispatches = directDispatches.Where(dispatch =>
dispatch.WorkOrder != null && dispatch.WorkOrder.AccountId == directAccountId);
var direct = directDispatches
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
.Select(dispatch => new ScheduledDispatchReadModel
{
Id = dispatch.Id,
@ -83,23 +71,18 @@ 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 linkedDispatches = _context.DispatchWorkOrders.AsNoTracking()
var linked = _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));
if (accountId is int linkedAccountId)
linkedDispatches = linkedDispatches.Where(link => link.WorkOrder!.AccountId == linkedAccountId);
var linked = linkedDispatches
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
.Select(link => new ScheduledDispatchReadModel
{
Id = link.DispatchId,
@ -107,7 +90,6 @@ 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)

View file

@ -21,8 +21,7 @@ namespace SeaHaven.DataServices.Implementation
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
=> _context.workOrders
.Include(workOrder => workOrder.Locations!)
.ThenInclude(location => location.Contacts)
.Include(workOrder => workOrder.Locations)
.Include(workOrder => workOrder.WorkOrderContacts!)
.ThenInclude(contact => contact.POC)
.Include(workOrder => workOrder.PrimaryDispatch!)

View file

@ -1,47 +0,0 @@
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);
}
}
}

View file

@ -1,52 +0,0 @@
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);
}
}

View file

@ -8,8 +8,6 @@ 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);

View file

@ -1,28 +0,0 @@
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);
}
}

View file

@ -71,9 +71,7 @@ namespace SeaHaven.DataServices.Interfaces
int MediaCount = 0,
WorkOrderFrozenSite? FrozenSite = null,
WorkOrderFrozenCompany? FrozenCompany = null,
WorkOrderFrozenPoc? FrozenPoc = null,
string? WoPocName = null,
string? WoPocPhone = null);
WorkOrderFrozenPoc? FrozenPoc = null);
public record WorkOrderBoardQueryResult(
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,

View file

@ -1,66 +0,0 @@
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; }
}

View file

@ -7,7 +7,6 @@ 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; }
}

View file

@ -1,134 +0,0 @@
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>();
}
}

View file

@ -12,10 +12,6 @@ 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; }

View file

@ -92,8 +92,6 @@ 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; }

View file

@ -58,20 +58,6 @@ 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
{

View file

@ -1,220 +0,0 @@
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);
}
}

View file

@ -1,57 +0,0 @@
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));
}
}
}

View file

@ -1,34 +0,0 @@
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);
}
}
}
}
}
}

View file

@ -17,14 +17,10 @@ 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),
WorkOrderPocSiteContact.DisplayName(sitePrimary));
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber);
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName));
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber);
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes);
var contacts = new List<WorkOrderFrozenPocContact>();

View file

@ -32,18 +32,17 @@ namespace SeaHaven.Services.Helpers
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
};
// 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.
// 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" };
private static string? ResolveContentType(string declaredType, string extension)
{
if (AllowedContentTypes.Contains(declaredType))
return declaredType;
if (!UndeterminedContentTypes.Contains(declaredType))
return AllowedContentTypes.Contains(declaredType) ? declaredType : null;
foreach (var (contentType, extensions) in ExtensionsByContentType)
{

View file

@ -1,51 +0,0 @@
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();
}
}

View file

@ -1,236 +0,0 @@
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);
}
}

View file

@ -49,9 +49,6 @@ 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,

View file

@ -3,7 +3,6 @@ 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
@ -434,9 +433,32 @@ 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)
=> VendorConflictPairs.Build(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);
}
}
}
}
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
=> leftStart < rightEnd && rightStart < leftEnd;

View file

@ -20,7 +20,6 @@ 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(
@ -30,7 +29,6 @@ namespace SeaHaven.Services.Implementation
IWorkOrderAuditService auditService,
IWorkOrderBoardCreateValidation validator,
IWorkOrderAccountResolver accountResolver,
IWorkOrderPocDataService pocData,
IServicesRegistryService? servicesRegistryService = null)
{
_boardDataService = boardDataService;
@ -39,7 +37,6 @@ namespace SeaHaven.Services.Implementation
_auditService = auditService;
_validator = validator;
_accountResolver = accountResolver;
_pocData = pocData;
_servicesRegistryService = servicesRegistryService;
}
@ -67,25 +64,6 @@ 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);

View file

@ -116,7 +116,6 @@ 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),

View file

@ -1,176 +0,0 @@
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);
}
}

View file

@ -1,14 +0,0 @@
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);
}
}

View file

@ -1,18 +0,0 @@
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);
}
}

View file

@ -80,91 +80,6 @@ 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)

View file

@ -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, new WorkOrderPocDataService(context));
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
}
[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, new WorkOrderPocDataService(context));
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
using var cts = new CancellationTokenSource();
await create.CreateAsync(

View file

@ -49,8 +49,7 @@ public class WorkOrderBoardCreateRelationalTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context));
resolver);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
@ -112,8 +111,7 @@ public class WorkOrderBoardCreateRelationalTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context));
resolver);
await Assert.ThrowsAsync<DbUpdateException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto

View file

@ -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, new WorkOrderPocDataService(context));
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver);
return (context, service);
}

View file

@ -32,8 +32,7 @@ public class WorkOrderBoardCreateSyncLockTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context));
resolver);
var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
@ -82,8 +81,7 @@ public class WorkOrderBoardCreateSyncLockTests
boardService,
audit,
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context));
resolver);
var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto

View file

@ -2084,40 +2084,11 @@ public class WorkOrderMediaFileRulesTests
}
[Fact]
public void IsAllowed_AllowlistedDeclaredMimeMismatchingExtension_StillRejected()
public void IsAllowed_DeclaredMimeMismatchingExtension_StillRejected()
{
// 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.
// A concrete declared type is authoritative; only an undetermined one falls back to the extension.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4")));
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));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic")));
}
private static byte[] BytesFor(string fileName)

View file

@ -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, new WorkOrderPocDataService(context));
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
var policy = new SyncFieldMergePolicy(fieldLocks);
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));

View file

@ -1,641 +0,0 @@
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 Normal file
View file

@ -0,0 +1,52 @@
# 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

View file

@ -41,16 +41,6 @@ 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"
@ -62,16 +52,16 @@ log "G2: architecture boundary tests (dependency direction)"
--nologo
ok "G2: ArchitectureTests"
log "G3: changed-file formatting (${BASE_REF}...${HEAD_REF}, merge base ${DIFF_BASE:0:7})"
log "G3: changed-file formatting (${BASE_REF}..${HEAD_REF})"
changed_cs=()
while IFS= read -r f; do
changed_cs+=("$f")
done < <(
git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}" -- '*.cs'
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${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" \
@ -99,9 +89,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}, merge base ${DIFF_BASE:0:7})"
log "G13: application and Terraform isolation (${BASE_REF}..${HEAD_REF})"
python3 scripts/check_app_terraform_isolation.py < <(
git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}"
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}"
)
ok "G13: application and Terraform isolation"