Compare commits

...

26 commits

Author SHA1 Message Date
Adam Moussa
752f5fdea1
Merge pull request #162 from Sea-Haven-Industries/fix/governance-diff-merge-base
Some checks are pending
Architecture and changed-file quality / architecture (push) Waiting to run
Terraform CI / terraform (push) Waiting to run
Backend CI / Build and test (push) Waiting to run
fix(governance): diff G3 and G13 from the merge base, not the base tip
2026-09-18 23:19:33 +00:00
Adam Moussa
ae35f10921
Merge pull request #161 from Sea-Haven-Industries/chore/repo-docs-and-template
chore(repo): add PR template and README, retire stale root files
2026-09-18 23:18:09 +00:00
Alexandre Brandizzi
637ba3350b
Merge pull request #152 from Sea-Haven-Industries/feat/ab/sh-288-event-notifications
feat(notifications): assignment, comment, mention and uplift-decision items in the feed (SH-289, SH-288, SH-215)
2026-09-18 23:16:38 +00:00
24e4f2b7f7
fix(governance): diff G3 and G13 from the merge base, not the base tip
The gate computed changed files with a two-dot diff against the PR base tip,
so everything main gained after the branch point counted as this change. A
branch behind main that touched C# failed G13 whenever main had merged
Terraform in between, which is how #152 failed after #154, #156 and #158
landed. The merge queue no longer requires branches to be current, so the
false positive would have hit every stale PR. Both diffs now start at the
merge base. Push and merge-group runs are unchanged because their base is an
ancestor of the head.
2026-09-18 19:12:21 -04:00
Adam Moussa
019eb86894
Merge branch 'main' into feat/ab/sh-288-event-notifications 2026-09-18 19:11:15 -04:00
b7b22a8893
chore(repo): add PR template and README, retire stale root files
The org PR template pre-filled Summary / Validation / Tests / Notes here while
REVIEW_AND_PR_FRAMEWORK.md section 8 prescribes Summary / Changes and value /
Ticket. A repo template now overrides the org one, and the framework notes the
divergence from the org pr-policy workflow, which is not wired in.

README.md orients a reader: environments, architecture in one line, project
map, local commands, the governance gate, deployment, and a documentation map.

Cleanup: TODO.md is removed because Jira owns work status and its items are
stale or done. BACKEND_ARCHITECTURE.md is removed as superseded; the two
references now point at git history. .env.example loses its BOM and mojibake
dashes. .gitattributes keeps its one active rule.
2026-09-18 19:05:30 -04:00
Adam Moussa
10266e6e4b
Merge pull request #160 from Sea-Haven-Industries/renovate/reconfigure
chore(renovate): widen the schedule, track the EF tool pin, hold EF majors
2026-09-18 22:59:37 +00:00
Alexandre Brandizzi
e1ce3e439b
Merge pull request #149 from Sea-Haven-Industries/feat/ab/sh-292-notification-center
SH-292: Notification Center feed endpoint
2026-09-18 22:54:54 +00:00
Alexandre Brandizzi
0b3084a274
Merge pull request #155 from Sea-Haven-Industries/fix/ab/sh-379-manual-poc-override
Persist manual POC override with audit and site-follow (SH-379)
2026-09-18 22:54:51 +00:00
Alexandre Brandizzi
cd4d30af4b
Merge pull request #157 from Sea-Haven-Industries/fix/ab/sh-381-mobile-video-mime
fix(work-orders): accept mobile media whose declared MIME is foreign (SH-381)
2026-09-18 22:54:48 +00:00
Alexandre Brandizzi
a9773e67eb
Merge pull request #151 from Sea-Haven-Industries/feat/ab/sh-209-uplift-detail-context
SH-209: Expose dispatcher, technician and schedule on the uplift queue read
2026-09-18 22:53:56 +00:00
b270543d0d
chore(renovate): widen the schedule, track the EF tool pin, hold EF majors
The org window (before 6am on Monday) has produced no PRs in this repository
since the overlay landed, so the overlay now opens every weekday morning. A
regex manager tracks EF_VERSION in the Elastic Beanstalk packaging script,
which installs dotnet-ef at deploy time and would otherwise fall behind the
tool manifest and EF Core packages; it joins the nuget minor and patch group.
ASP.NET Core, EF Core and dotnet-ef majors are disabled while the target is
net8.0 so the dashboard approval list only shows updates that can be taken.
2026-09-18 18:48:12 -04:00
Alexandre Brandizzi
582af8fb2c fix(workorders): compare manual POC against the followed contact, not any site contact
The manual POC "follow the site" clear-rule compared the edit against every
live Site contact. A work order only ever displays one of them, so editing to
a different live contact (Site has Alice primary and Bob; WO shows Alice; edit
to Bob) matched, cleared the override, and left the row showing Alice with no
audit row written — the SH-379 symptom on a different input. A work order with
a linked WorkOrderContacts POC hit the same bug when the dispatcher typed the
Site's primary: the override cleared and the linked contact showed instead.

Compare the edit against the single contact the work order actually follows —
the linked WorkOrderContacts POC, or else the Site primary (ResolvePrimary) —
matching the board projection's override -> linked -> site precedence, and
store the override whenever the edit differs from it. The create path in
WorkOrderBoardCreateService had the same any-contact rule and gets the same
fix; a supplied PocContactId that is not a live Site contact leaves no follow
target, so the typed POC is stored.

Replaces MatchesAnySiteContact with Matches(name, phone, contact); comment and
PR-body wording updated to state the followed-contact rule. Adds tests for the
second-site-contact edit, the linked-contact-differs edit, and the
second-site-contact create case.
2026-09-18 18:56:29 -03:00
Adam Moussa
51417a65b5
Merge branch 'main' into fix/ab/sh-381-mobile-video-mime 2026-09-18 17:32:06 -04:00
Adam Moussa
503faa8c25
Merge branch 'main' into fix/ab/sh-379-manual-poc-override 2026-09-18 17:31:45 -04:00
Adam Moussa
1cd3c802e3
Merge branch 'main' into feat/ab/sh-209-uplift-detail-context 2026-09-18 17:31:12 -04:00
Adam Moussa
0556822d22
Merge branch 'main' into feat/ab/sh-292-notification-center 2026-09-18 17:30:22 -04:00
Alexandre Brandizzi
46eed22707 fix(work-orders): accept mobile media whose declared MIME is foreign (SH-381)
Mobile browsers attach an unreliable Content-Type to a picked file: empty or
application/octet-stream when the OS cannot classify it, and sometimes a
foreign-but-plausible type for a supported container (video/3gpp for an .mp4,
video/x-quicktime for a .mov). The media allowlist already resolved empty and
octet-stream types from the extension, but a concrete foreign type was rejected
outright, so a real .MP4/.MOV picked on a phone passed the client dialog and was
refused by the API.

Any declared type that is not itself on the allowlist now falls back to the
extension. The extension pairing and magic-byte signature still decide, so the
accepted set of files is unchanged; an allowlisted declared type stays
authoritative and must still match its own extension.
2026-09-18 16:33:24 -03:00
Alexandre Brandizzi
cbecc7b4ea fix(workorders): persist manual POC override with audit and site-follow (SH-379)
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.

- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
  + WorkOrderPocDataService: persists the override, stages FieldChanged audit
  entries (which also write field locks so sync never overwrites a manual POC),
  and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
  stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
  stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
  fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
  WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
2026-09-18 14:30:54 -03:00
Alexandre Brandizzi
9784dcf895 Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into feat/ab/sh-288-event-notifications 2026-09-18 13:20:22 -03:00
Alexandre Brandizzi
e668e13912 feat(notifications): feed assignments, comments, mentions and uplift decisions to the user they concern
Adds the personal producers behind GET /api/notifications without changing its shape:
new assignments (SH-288), unanswered comments on work the user takes part in (SH-289),
@mentions, and decisions on uplifts the user requested (SH-215).
2026-09-18 13:15:33 -03:00
Alexandre Brandizzi
c9fa4a49af style(notifications): fix object initializer indentation in NotificationFeedService 2026-09-18 13:09:46 -03:00
Alexandre Brandizzi
aad3facaf1 fix(notifications): ignore soft-deleted dispatches and cap conflicts by recency
No Vendor treated any non-terminal dispatch as an assigned vendor without
checking IsDeleted, so a soft-deleted dispatch hid the work order from both
No Vendor and Vendor Conflict (the conflict query already drops deleted
dispatches). GetNoVendorAsync and the vendor-reminder assigned-work-order rule
now skip soft-deleted dispatches, keeping the asserted parity between the feed
and the reminders.

Vendor Conflict applied the section cap in vendor-sweep order, so when overlaps
exceeded the limit newer conflicts could be dropped while Count still counted
every work order. VendorConflictsAsync now orders pairs by recency before the
cap, matching the other per-work-order sections.
2026-09-18 12:59:56 -03:00
Alexandre Brandizzi
ad68bccb89 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-209-uplift-detail-context 2026-09-18 12:58:27 -03:00
Alexandre Brandizzi
fa979605b4 feat(uplifts): expose dispatcher, technician and schedule on queue read (SH-209)
The uplift detail modal needs the work order's assigned dispatcher, the
requesting vendor's technician and the scheduled date. They now resolve
from the same effective work order and vendor as the existing queue row,
so the modal no longer depends on a separate work-order fetch that
account-scoped staff cannot read.
2026-09-18 12:49:25 -03:00
Alexandre Brandizzi
da0b29f769 feat(notifications): serve the Notification Center feed grouped by reason
Adds GET /api/notifications, a per-user read model derived from live
work-order state: Unassigned (grouped, High), No Vendor and Aveta Missing
(per work order, Medium) and Vendor Conflict, account-scoped from claims
and ordered by section severity with a fixed reason tie-break.
2026-09-18 12:40:37 -03:00
57 changed files with 3678 additions and 294 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,63 +1,2 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
# Normalize line endings on checkout and checkin.
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,22 @@
<!--
Title: type(scope): description (SH-123)
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
Docs-only and configuration-only chores may omit the key.
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
Scope: one logical change per PR. If the title needs "and", split it.
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
-->
## Summary
<!-- What changed and why, in plain language. Two to four sentences. -->
## Changes and value
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
## Ticket
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->

34
.github/renovate.json vendored
View file

@ -1,8 +1,22 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["nuget", "github-actions", "terraform"],
"enabledManagers": ["nuget", "github-actions", "terraform", "custom.regex"],
"schedule": ["before 6am every weekday"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"customManagers": [
{
"customType": "regex",
"description": [
"EF_VERSION in the Elastic Beanstalk packaging script installs dotnet-ef at deploy time and must move with the tool manifest and EF Core packages"
],
"managerFilePatterns": ["/^scripts/package-elastic-beanstalk\\.sh$/"],
"matchStrings": ["EF_VERSION=\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
"datasourceTemplate": "nuget",
"depNameTemplate": "dotnet-ef",
"versioningTemplate": "nuget"
}
],
"packageRules": [
{
"description": [
@ -12,8 +26,10 @@
"dependencyDashboardApproval": true
},
{
"description": ["Group non-major nuget updates"],
"matchManagers": ["nuget"],
"description": [
"Group non-major nuget updates, including the dotnet-ef pin in the packaging script"
],
"matchManagers": ["nuget", "custom.regex"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "nuget minor and patch"
},
@ -35,6 +51,18 @@
"matchUpdateTypes": ["major"],
"groupName": "aspnetcore and ef core"
},
{
"description": [
"net8.0 target: hold ASP.NET Core, EF Core, and dotnet-ef at 8.x until the TargetFramework moves; the group above takes over then"
],
"matchPackageNames": [
"Microsoft.AspNetCore{/,}**",
"Microsoft.EntityFrameworkCore{/,}**",
"dotnet-ef"
],
"matchUpdateTypes": ["major"],
"enabled": false
},
{
"description": ["Keep AWS SDK majors together"],
"matchPackageNames": ["/^AWSSDK\\./"],

View file

@ -6,9 +6,7 @@ may add stricter backend rules but may never weaken the workspace baseline.
## Canonical governance documents (precedence)
1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical;
supersedes the older `BACKEND_ARCHITECTURE.md`, which is retained only as
historical reference).
1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical).
2. `QUALITY_GATES.md` — *how* rules are enforced (commands + CI mapping).
3. `REVIEW_AND_PR_FRAMEWORK.md` — *who* reviews, in what order, with what
evidence.

View file

@ -1,9 +1,8 @@
# Architecture and Code Quality (canonical)
> **Status: canonical.** This document owns the *meaning* of the backend's
> architecture and code-quality rules. On any conflict with the older
> `BACKEND_ARCHITECTURE.md`, **this document governs**; that file is retained
> only as historical reference pending removal.
> architecture and code-quality rules. It replaced the earlier
> `BACKEND_ARCHITECTURE.md`, which now lives only in git history.
>
> Companion documents:
> - `QUALITY_GATES.md` — *how* each rule is enforced (commands + CI mapping).

View file

@ -0,0 +1,452 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Items addressed to the signed-in user: new assignments (SH-288), comments (SH-289),
/// mentions, and decisions on their uplift requests (SH-215).
/// </summary>
public class NotificationActivityFeedTests
{
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
private const string Me = "dispatcher-me";
private const string Other = "dispatcher-other";
private const string Admin = "admin-1";
private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(Now);
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.Users.AddRange(
new ApplicationUser { Id = Me, FirstName = "Maya", LastName = "Reed" },
new ApplicationUser { Id = Other, FirstName = "Omar", LastName = "Diaz" },
new ApplicationUser { Id = Admin, FirstName = "Ada", LastName = "Stone" });
context.SaveChanges();
return context;
}
private static NotificationFeedService NewService(ApplicationDbContext context)
=> new(
new NotificationFeedDataService(context),
new VendorOperationsDataService(context, new DispatchDataService(context)),
new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)),
new FixedTimeProvider());
private static ClaimsPrincipal User(int accountId, string role, string userId)
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static ClaimsPrincipal Dispatcher(int accountId = 1, string userId = Me) => User(accountId, "Dispatcher", userId);
private static WorkOrder AddWorkOrder(ApplicationDbContext context, int accountId, string? assignTo, string number,
LifecycleStatus status = LifecycleStatus.Pending)
{
var workOrder = new WorkOrder
{
AccountId = accountId,
AssignTo = assignTo,
InternalWONumber = number,
LifecycleStatus = status,
CreatedDate = Now.AddDays(-30)
};
context.workOrders.Add(workOrder);
context.SaveChanges();
return workOrder;
}
private static void Assigned(ApplicationDbContext context, WorkOrder workOrder, string? by, DateTime at, string field = "AssignTo")
{
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = workOrder.Id,
UserId = by,
FieldName = field,
OldValue = "",
NewValue = workOrder.AssignTo,
Action = "AssignmentChanged",
CreatedAt = at
});
context.SaveChanges();
}
private static Comments Comment(ApplicationDbContext context, WorkOrder workOrder, string? by, string text, DateTime at,
string? commenter = null)
{
var comment = new Comments
{
WorkerOrderId = workOrder.Id,
UserId = by,
Commenter = commenter,
Commenttext = text,
CommentType = "General",
RecordType = "WorkOrder",
CreatedDate = at
};
context.Comments.Add(comment);
context.SaveChanges();
return comment;
}
private static DispatchUpliftRequest Uplift(ApplicationDbContext context, WorkOrder workOrder, string requestedBy,
string status, decimal amount, string? decidedBy, DateTime? decidedAt, string? note = null)
{
var dispatch = new Dispatch { WorkOrderId = workOrder.Id, Status = "Sent" };
context.Dispatches.Add(dispatch);
context.SaveChanges();
var uplift = new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
RequestedNTE = amount,
Status = status,
createdby = requestedBy,
DecidedAt = decidedAt,
DecidedByUserId = decidedBy,
DecisionNote = note,
CreatedDate = Now.AddDays(-2)
};
context.DispatchUpliftRequests.Add(uplift);
context.SaveChanges();
return uplift;
}
private static IReadOnlyList<NotificationItemDto> Items(NotificationFeedDto feed, string reason)
=> feed.Sections.SingleOrDefault(section => section.Reason == reason)?.Items
?? Array.Empty<NotificationItemDto>();
// ---- SH-288 new assignments ----
[Fact]
public async Task Assignment_BySomeoneElse_IsOneLowActivityItemThatOpensTheWorkOrder()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Me, "WO-100");
Assigned(context, workOrder, Other, Now.AddHours(-3));
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
item.Id.Should().Be($"new-assigned-{workOrder.Id}");
item.Severity.Should().Be(NotificationSeverities.Low);
item.RowType.Should().Be(NotificationRowTypes.Dismissable);
item.Title.Should().Be("WO #WO-100 was assigned to you");
item.Count.Should().Be(1);
item.TriggeredAt.Should().Be(Now.AddHours(-3));
item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc);
item.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder);
item.Target.WorkOrderId.Should().Be(workOrder.Id);
item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Info);
feed.Sections.Single(s => s.Reason == NotificationReasons.Activity).Label.Should().Be("Recent activity");
}
[Fact]
public async Task Assignments_MadeInOneAction_BecomeOneGroupedItemThatOpensTheUsersQueue()
{
using var context = NewContext();
var first = AddWorkOrder(context, 1, Me, "WO-1");
var second = AddWorkOrder(context, 1, Me, "WO-2");
var third = AddWorkOrder(context, 1, Me, "WO-3");
var later = AddWorkOrder(context, 1, Me, "WO-4");
Assigned(context, first, Other, Now.AddHours(-5));
Assigned(context, second, Other, Now.AddHours(-5).AddSeconds(20));
Assigned(context, third, Other, Now.AddHours(-5).AddSeconds(50));
// Same person, an hour later: a separate action.
Assigned(context, later, Other, Now.AddHours(-4));
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
var items = Items(feed, NotificationReasons.Activity);
items.Should().HaveCount(2);
var grouped = items.Single(i => i.Count == 3);
grouped.Id.Should().Be($"new-assigned-group-{first.Id}");
grouped.Title.Should().Be("3 new work orders assigned to you");
grouped.Severity.Should().Be(NotificationSeverities.Low);
grouped.Target.Kind.Should().Be(NotificationTargetKinds.Queue);
grouped.Target.Queue.Should().Be(NotificationQueues.AssignedToMe);
grouped.WorkOrders.Select(w => w.Id).Should().BeEquivalentTo(new[] { first.Id, second.Id, third.Id });
items.Single(i => i.Count == 1).Target.WorkOrderId.Should().Be(later.Id);
}
[Fact]
public async Task Assignment_IsNotShownWhenSelfMadeStaleClosedOrReassignedAway()
{
using var context = NewContext();
Assigned(context, AddWorkOrder(context, 1, Me, "SELF"), Me, Now.AddHours(-1));
Assigned(context, AddWorkOrder(context, 1, Me, "STALE"), Other, Now.AddDays(-8));
Assigned(context, AddWorkOrder(context, 1, Me, "DONE", LifecycleStatus.Completed), Other, Now.AddHours(-1));
var movedAway = AddWorkOrder(context, 1, Me, "MOVED");
Assigned(context, movedAway, Other, Now.AddHours(-2));
movedAway.AssignTo = Other;
Assigned(context, movedAway, Admin, Now.AddHours(-1));
// Reassigned to me earlier, then someone re-saved it to me again recently: the latest entry counts.
var reassigned = AddWorkOrder(context, 1, Me, "LATEST");
Assigned(context, reassigned, Other, Now.AddDays(-9));
Assigned(context, reassigned, Admin, Now.AddMinutes(-10));
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
Items(feed, NotificationReasons.Activity).Should().ContainSingle()
.Which.Target.WorkOrderId.Should().Be(reassigned.Id);
}
[Fact]
public async Task Assignment_CountsLegacyAuditEntriesButNotUnauditedAssignees()
{
using var context = NewContext();
var legacy = AddWorkOrder(context, 1, Me, "LEGACY");
Assigned(context, legacy, Other, Now.AddDays(-1), field: "AssignedTo");
// Assigned at some point with no audit trail: there is no assignment event to report.
var unaudited = AddWorkOrder(context, 1, Me, "UNAUDITED");
unaudited.CreatedDate = Now.AddHours(-2);
context.SaveChanges();
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
Items(feed, NotificationReasons.Activity).Should().ContainSingle()
.Which.Target.WorkOrderId.Should().Be(legacy.Id);
}
// ---- SH-289 comments and mentions ----
[Fact]
public async Task Comments_BySomeoneElseOnMyWorkOrder_GroupIntoOneMediumItemOnTheCommentsTab()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Me, "WO-7");
var first = Comment(context, workOrder, Other, "Vendor is on site", Now.AddHours(-3));
var second = Comment(context, workOrder, null, "Reply from the email thread", Now.AddHours(-2), commenter: "Site Lead");
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
item.Id.Should().Be($"comments-{workOrder.Id}-{second.Id}");
item.Title.Should().Be("2 new comments on WO #WO-7");
item.Severity.Should().Be(NotificationSeverities.Medium);
item.Count.Should().Be(1);
// The group starts at its earliest comment; the Comments tab highlights from there.
item.TriggeredAt.Should().Be(first.CreatedDate!.Value);
item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc);
item.Target.WorkOrderId.Should().Be(workOrder.Id);
item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments);
}
[Fact]
public async Task Comment_Single_ShowsTheCommenterAndAOneLinePreview()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Me, "WO-8");
var text = "Please ask @[" + Admin + ":Ada Stone] about the gate code\nbefore the vendor arrives, because the "
+ "site closes early on Fridays and security will not let anyone in after four";
Comment(context, workOrder, Other, text, Now.AddHours(-1));
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
var title = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject.Title;
title.Should().StartWith("Omar Diaz commented on WO #WO-8: “Please ask @Ada Stone about the gate code before");
title.Should().EndWith("…”");
NotificationActivityItems.Preview(text).Should().HaveLength(80);
}
[Fact]
public async Task Comments_NeverNotifyTheirAuthorAndClearOnceTheUserReplies()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Me, "WO-9");
Comment(context, workOrder, Me, "My own note", Now.AddHours(-5));
Comment(context, workOrder, Other, "Answered already", Now.AddHours(-4));
Comment(context, workOrder, Me, "Thanks", Now.AddHours(-3));
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
Items(feed, NotificationReasons.Activity).Should().BeEmpty();
var unanswered = Comment(context, workOrder, Other, "One more thing", Now.AddHours(-1));
feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
item.Title.Should().Be("Omar Diaz commented on WO #WO-9: “One more thing”");
item.TriggeredAt.Should().Be(unanswered.CreatedDate!.Value);
}
[Fact]
public async Task Comments_ReachPriorCommentersButNotBystanders()
{
using var context = NewContext();
// Someone else's work order that I commented on earlier this month.
var joined = AddWorkOrder(context, 1, Other, "JOINED");
Comment(context, joined, Me, "Adding context", Now.AddDays(-20));
Comment(context, joined, Other, "Follow-up", Now.AddHours(-2));
var bystander = AddWorkOrder(context, 1, Other, "NOT-MINE");
Comment(context, bystander, Admin, "Internal note", Now.AddHours(-2));
var stale = AddWorkOrder(context, 1, Me, "OLD");
Comment(context, stale, Other, "Last week", Now.AddDays(-8));
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
Items(feed, NotificationReasons.Activity).Should().ContainSingle()
.Which.Target.WorkOrderId.Should().Be(joined.Id);
// The owner of NOT-MINE is notified of the admin's note, and not of their own follow-up.
var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None);
Items(ownerFeed, NotificationReasons.Activity).Should().ContainSingle()
.Which.Target.WorkOrderId.Should().Be(bystander.Id);
}
[Fact]
public async Task Mention_IsAHighMentionsItemAndIsNotAlsoCountedAsAComment()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Me, "WO-11");
Comment(context, workOrder, Other, "Plain update", Now.AddHours(-3));
var mention = Comment(context, workOrder, Other, "@[" + Me + ":Maya Reed] can you confirm?", Now.AddHours(-2));
// A mention reaches the user even on a work order they are not otherwise part of.
var elsewhere = AddWorkOrder(context, 1, Other, "WO-12");
Comment(context, elsewhere, Admin, "Looping in @[" + Me + ":Maya Reed]", Now.AddHours(-1));
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
var mentions = Items(feed, NotificationReasons.Mentions);
mentions.Should().HaveCount(2);
var onMine = mentions.Single(i => i.Target.WorkOrderId == workOrder.Id);
onMine.Id.Should().Be($"mention-{mention.Id}");
onMine.Title.Should().Be("Omar Diaz mentioned you on WO #WO-11");
onMine.Severity.Should().Be(NotificationSeverities.High);
onMine.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments);
onMine.TriggeredAt.Should().Be(mention.CreatedDate!.Value);
mentions.Single(i => i.Target.WorkOrderId == elsewhere.Id).Title.Should().Be("Ada Stone mentioned you on WO #WO-12");
var activity = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject;
activity.Title.Should().Be("Omar Diaz commented on WO #WO-11: “Plain update”");
feed.Sections.Select(s => s.Reason).Should().ContainInOrder(NotificationReasons.Mentions, NotificationReasons.Activity);
}
[Fact]
public void MentionsUser_MatchesOnlyTheUsersOwnToken()
{
NotificationActivityItems.MentionsUser("hi @[" + Me + ":Maya Reed]", Me).Should().BeTrue();
NotificationActivityItems.MentionsUser("hi @[" + Me + "x:Someone Else]", Me).Should().BeFalse();
NotificationActivityItems.MentionsUser("hi @Maya Reed", Me).Should().BeFalse();
NotificationActivityItems.MentionsUser(null, Me).Should().BeFalse();
}
// ---- SH-215 uplift decisions ----
[Fact]
public async Task UpliftDecisions_OnMyRequests_ShowAmountAndReasonAndOpenTheUplifts()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Other, "WO-20");
var approved = Uplift(context, workOrder, Me, "Approved", 1250m, Admin, Now.AddHours(-3));
var rejected = Uplift(context, workOrder, Me, "Rejected", 400m, Admin, Now.AddHours(-2), "Quote does not match the scope agreed");
var revoked = Uplift(context, workOrder, Me, "Revoked", 90.5m, Admin, Now.AddHours(-1), "Duplicate of an earlier uplift");
var legacy = Uplift(context, workOrder, Me, "Denied", 75m, Admin, Now.AddHours(-4), "Old wording");
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
var items = Items(feed, NotificationReasons.Activity);
items.Should().HaveCount(4);
var approvedItem = items.Single(i => i.Id == $"uplift-approved-{approved.Id}");
approvedItem.Title.Should().Be("Your $1,250.00 uplift on WO #WO-20 was approved");
approvedItem.Severity.Should().Be(NotificationSeverities.Medium);
approvedItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Uplifts);
approvedItem.Target.WorkOrderId.Should().Be(workOrder.Id);
var rejectedItem = items.Single(i => i.Id == $"uplift-rejected-{rejected.Id}");
rejectedItem.Title.Should().Be("Your $400.00 uplift on WO #WO-20 was rejected: “Quote does not match the scope agreed”");
rejectedItem.Severity.Should().Be(NotificationSeverities.High);
var revokedItem = items.Single(i => i.Id == $"uplift-revoked-{revoked.Id}");
revokedItem.Title.Should().Be("Your $90.50 uplift on WO #WO-20 was revoked: “Duplicate of an earlier uplift”");
revokedItem.Severity.Should().Be(NotificationSeverities.High);
revokedItem.TriggeredAt.Should().Be(Now.AddHours(-1));
items.Single(i => i.Id == $"uplift-rejected-{legacy.Id}").Severity.Should().Be(NotificationSeverities.High);
// The requester need not be the dispatcher who owns the work order.
var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None);
Items(ownerFeed, NotificationReasons.Activity).Should().BeEmpty();
}
[Fact]
public async Task UpliftDecisions_SkipPendingAutoApprovedSelfRevokedAndStaleRequests()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Me, "WO-21");
Uplift(context, workOrder, Me, "Pending", 100m, null, null);
Uplift(context, workOrder, Me, "NoApprovalRequired", 100m, null, null);
Uplift(context, workOrder, Me, "Revoked", 100m, Me, Now.AddHours(-1));
Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddDays(-8));
var deleted = Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddHours(-1));
deleted.IsDeleted = true;
context.SaveChanges();
var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None);
Items(feed, NotificationReasons.Activity).Should().BeEmpty();
}
// ---- scope ----
[Fact]
public async Task PersonalItems_NeverCrossAccounts()
{
using var context = NewContext();
var foreign = AddWorkOrder(context, 2, Me, "FOREIGN");
Assigned(context, foreign, Other, Now.AddHours(-1));
Comment(context, foreign, Other, "@[" + Me + ":Maya Reed] hello", Now.AddHours(-1));
Uplift(context, foreign, Me, "Approved", 10m, Admin, Now.AddHours(-1));
var sameUserOtherAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 1), CancellationToken.None);
sameUserOtherAccount.Sections.Should().BeEmpty();
var inAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 2), CancellationToken.None);
Items(inAccount, NotificationReasons.Mentions).Should().ContainSingle();
Items(inAccount, NotificationReasons.Activity).Should().HaveCount(2);
}
[Fact]
public async Task PersonalItems_ReachEveryFeedRoleByUserId()
{
using var context = NewContext();
var workOrder = AddWorkOrder(context, 1, Other, "WO-30");
Comment(context, workOrder, Other, "@[" + Admin + ":Ada Stone] please approve", Now.AddHours(-1));
var adminFeed = await NewService(context).GetFeedAsync(User(1, "Admin", Admin), CancellationToken.None);
Items(adminFeed, NotificationReasons.Mentions).Should().ContainSingle()
.Which.Title.Should().Be("Omar Diaz mentioned you on WO #WO-30");
var managerFeed = await NewService(context).GetFeedAsync(User(1, "Manager", "manager-1"), CancellationToken.None);
Items(managerFeed, NotificationReasons.Mentions).Should().BeEmpty();
Items(managerFeed, NotificationReasons.Activity).Should().BeEmpty();
}
[Fact]
public async Task PersonalQueries_ForwardCancellation()
{
using var context = NewContext();
var data = new NotificationFeedDataService(context);
var scope = new NotificationPersonalScope(1, Me);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
await FluentActions.Awaiting(() => data.GetRecentAssignmentsAsync(scope, Now, 10, cancelled.Token))
.Should().ThrowAsync<OperationCanceledException>();
await FluentActions.Awaiting(() => data.GetUnansweredCommentsAsync(scope, Now, 10, cancelled.Token))
.Should().ThrowAsync<OperationCanceledException>();
await FluentActions.Awaiting(() => data.GetUpliftDecisionsAsync(scope, Now, 10, cancelled.Token))
.Should().ThrowAsync<OperationCanceledException>();
}
}

View file

@ -0,0 +1,588 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class NotificationFeedServiceTests
{
// 12:00 in New York, so the business date is 2026-09-18.
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Today = new(2026, 9, 18);
private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(Now);
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static NotificationFeedService NewService(ApplicationDbContext context)
{
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context));
return new NotificationFeedService(
new NotificationFeedDataService(context),
new VendorOperationsDataService(context, new DispatchDataService(context)),
resolver,
new FixedTimeProvider());
}
private static ClaimsPrincipal User(int accountId, string role, string userId = "user-1")
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static ClaimsPrincipal OrgWideAdmin()
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
private static WorkOrder Open(int accountId, string? assignTo = null, DateTime? scheduled = null, string? number = null)
=> new()
{
AccountId = accountId,
AssignTo = assignTo,
ScheduledDate = scheduled,
InternalWONumber = number,
LifecycleStatus = LifecycleStatus.Scheduled,
CreatedDate = Now.AddDays(-3)
};
private static WorkOrder AvetaDue(int accountId, string assignTo, DateTime scheduled, string number)
{
var workOrder = Open(accountId, assignTo, scheduled, number);
workOrder.AvetaRequired = true;
return workOrder;
}
private static NotificationSectionDto Section(NotificationFeedDto feed, string reason)
=> feed.Sections.Single(section => section.Reason == reason);
private static void AddActiveDispatch(ApplicationDbContext context, WorkOrder workOrder)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
context.Vendors.Add(vendor);
context.SaveChanges();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledDate
});
context.SaveChanges();
}
[Fact]
public async Task Unassigned_GroupsEveryOpenUnassignedWorkOrderIntoOneHighItem()
{
using var context = NewContext();
var newest = Open(1);
newest.CreatedDate = Now.AddHours(-1);
var completed = Open(1);
completed.LifecycleStatus = LifecycleStatus.Completed;
var canceled = Open(1);
canceled.LifecycleStatus = LifecycleStatus.Canceled;
var legacyDone = Open(1);
legacyDone.LifecycleStatus = null;
legacyDone.Status = "Done";
var template = Open(1);
template.istemplate = true;
var deleted = Open(1);
deleted.IsDeleted = true;
context.workOrders.AddRange(
Open(1), Open(1, assignTo: ""), newest, Open(1, assignTo: "dispatcher-1"),
completed, canceled, legacyDone, template, deleted);
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
var section = Section(feed, NotificationReasons.Unassigned);
section.Label.Should().Be("Unassigned");
section.Count.Should().Be(3);
var item = section.Items.Should().ContainSingle().Subject;
item.Id.Should().Be("unassigned");
item.Severity.Should().Be(NotificationSeverities.High);
item.RowType.Should().Be(NotificationRowTypes.Dismissable);
item.Title.Should().Be("3 work orders are unassigned");
item.Count.Should().Be(3);
item.TriggeredAt.Should().Be(newest.CreatedDate);
item.Target.Kind.Should().Be(NotificationTargetKinds.Queue);
item.Target.Queue.Should().Be(NotificationQueues.Unassigned);
}
[Fact]
public async Task Unassigned_CountFollowsAssignmentAndSectionDisappearsAtZero()
{
using var context = NewContext();
var first = Open(1);
var second = Open(1);
context.workOrders.AddRange(first, second);
await context.SaveChangesAsync();
var service = NewService(context);
first.AssignTo = "dispatcher-1";
await context.SaveChangesAsync();
var afterOne = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None);
var item = Section(afterOne, NotificationReasons.Unassigned).Items.Single();
item.Title.Should().Be("1 work order is unassigned");
item.Id.Should().Be("unassigned", "the id stays stable so a session dismissal survives count changes");
second.AssignTo = "dispatcher-2";
await context.SaveChangesAsync();
var afterAll = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None);
afterAll.Sections.Should().NotContain(section => section.Reason == NotificationReasons.Unassigned);
}
[Theory]
[InlineData("Admin")]
[InlineData("Manager")]
[InlineData("Scheduler")]
public async Task Unassigned_IsShownToRolesThatSeeEveryDispatcher(string role)
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, role), CancellationToken.None);
Section(feed, NotificationReasons.Unassigned).Count.Should().Be(1);
}
[Fact]
public async Task Unassigned_IsNotShownToDispatchers()
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
feed.Sections.Should().BeEmpty();
}
[Fact]
public async Task AvetaMissing_OnlyFlagsOpenRequiredWorkOrdersInTheOneDayWindowWithoutTheDocument()
{
using var context = NewContext();
var dueToday = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-TODAY");
var dueTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(1).AddHours(9), "A-TOMORROW");
var withDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(10), "A-HAS-DOC");
var withDeletedDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(11), "A-DELETED-DOC");
var dayAfterTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(2).AddHours(9), "A-LATER");
var yesterday = AvetaDue(1, "dispatcher-1", Today.AddDays(-1).AddHours(9), "A-PAST");
var completed = AvetaDue(1, "dispatcher-1", Today.AddHours(12), "A-DONE");
completed.LifecycleStatus = LifecycleStatus.Completed;
var notRequired = Open(1, "dispatcher-1", Today.AddHours(13), "A-NOT-REQUIRED");
context.workOrders.AddRange(
dueToday, dueTomorrow, withDocument, withDeletedDocument, dayAfterTomorrow, yesterday, completed, notRequired);
await context.SaveChangesAsync();
context.workOrderAttachments.AddRange(
new WorkOrderAttachments { WorkorderId = withDocument.Id, Category = WorkOrderMediaCategory.Aveta },
new WorkOrderAttachments { WorkorderId = withDeletedDocument.Id, Category = WorkOrderMediaCategory.Aveta, IsDeleted = true },
new WorkOrderAttachments { WorkorderId = dueToday.Id, Category = WorkOrderMediaCategory.Extra });
await context.SaveChangesAsync();
AddActiveDispatch(context, dueToday);
AddActiveDispatch(context, dueTomorrow);
AddActiveDispatch(context, withDeletedDocument);
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var section = Section(feed, NotificationReasons.AvetaMissing);
section.Label.Should().Be("Aveta Missing");
section.Count.Should().Be(3);
section.Items.Select(item => item.WorkOrders.Single().Number)
.Should().Equal("A-TOMORROW", "A-TODAY", "A-DELETED-DOC");
var tomorrowItem = section.Items[0];
tomorrowItem.Id.Should().Be($"aveta-missing-{dueTomorrow.Id}");
tomorrowItem.Severity.Should().Be(NotificationSeverities.Medium);
tomorrowItem.Title.Should().Be("WO #A-TOMORROW is missing the Aveta document");
tomorrowItem.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder);
tomorrowItem.Target.WorkOrderId.Should().Be(dueTomorrow.Id);
tomorrowItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Extras);
}
[Fact]
public async Task AvetaMissing_ClearsOnceTheDocumentIsAttached()
{
using var context = NewContext();
var workOrder = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-1");
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
AddActiveDispatch(context, workOrder);
var service = NewService(context);
var dispatcher = User(1, "Dispatcher", "dispatcher-1");
(await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections
.Should().ContainSingle(section => section.Reason == NotificationReasons.AvetaMissing);
context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = workOrder.Id, Category = WorkOrderMediaCategory.Aveta });
await context.SaveChangesAsync();
(await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections.Should().BeEmpty();
}
[Fact]
public async Task NoVendor_MatchesTheVendorReminderRuleWithinFortyEightHours()
{
using var context = NewContext();
var noVendor = Open(1, "dispatcher-1", Now.AddHours(24), "NV-1");
var withDispatch = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DISPATCHED");
var withLinkedDispatch = Open(1, "dispatcher-1", Now.AddHours(22), "NV-LINKED");
var withFinishedDispatch = Open(1, "dispatcher-1", Now.AddHours(30), "NV-FINISHED");
var tooFar = Open(1, "dispatcher-1", Now.AddHours(72), "NV-LATER");
context.workOrders.AddRange(noVendor, withDispatch, withLinkedDispatch, withFinishedDispatch, tooFar);
await context.SaveChangesAsync();
AddActiveDispatch(context, withDispatch);
var vendor = new Vendor { CompanyName = "Linked", IsActive = true };
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
var multi = new Dispatch { VendorId = vendor.Id, WorkOrderId = withDispatch.Id, Status = "Sent" };
var finished = new Dispatch { VendorId = vendor.Id, WorkOrderId = withFinishedDispatch.Id, Status = "Completed" };
context.Dispatches.AddRange(multi, finished);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = multi.Id, WorkOrderId = withLinkedDispatch.Id });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var section = Section(feed, NotificationReasons.NoVendor);
section.Label.Should().Be("No Vendor");
section.Items.Select(item => item.WorkOrders.Single().Number).Should().Equal("NV-FINISHED", "NV-1");
var item = section.Items.Single(row => row.Id == $"no-vendor-{noVendor.Id}");
item.Severity.Should().Be(NotificationSeverities.Medium);
item.Title.Should().Be("WO #NV-1 starts within 48h with no vendor assigned");
item.TriggeredAt.Should().Be(noVendor.ScheduledDate!.Value.AddHours(-48));
item.Target.WorkOrderId.Should().Be(noVendor.Id);
}
[Fact]
public async Task NoVendor_FlagsTheSameWorkOrdersAsTheVendorOperationsReminders()
{
using var context = NewContext();
var first = Open(1, "dispatcher-1", Now.AddHours(10), "P-1");
var second = Open(2, "dispatcher-2", Now.AddHours(40), "P-2");
var dispatched = Open(1, "dispatcher-1", Now.AddHours(12), "P-3");
context.workOrders.AddRange(first, second, dispatched);
await context.SaveChangesAsync();
AddActiveDispatch(context, dispatched);
var vendorService = new VendorOperationsService(
new VendorOperationsDataService(context, new DispatchDataService(context)), new FixedTimeProvider());
var reminders = await vendorService.GetNotificationsAsync(CancellationToken.None);
var feed = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None);
var reminderIds = reminders.Items.OfType<NoVendorNotification>().Select(item => item.Id);
Section(feed, NotificationReasons.NoVendor).Items.Select(item => item.Id)
.Should().BeEquivalentTo(reminderIds);
}
[Fact]
public async Task NoVendor_TreatsSoftDeletedDispatchesAsNoVendor()
{
using var context = NewContext();
var deletedDirect = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DEL-DIRECT");
var deletedLinked = Open(1, "dispatcher-1", Now.AddHours(22), "NV-DEL-LINKED");
context.workOrders.AddRange(deletedDirect, deletedLinked);
await context.SaveChangesAsync();
var vendor = new Vendor { CompanyName = "Removed", IsActive = true };
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
var direct = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true };
var linked = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true };
context.Dispatches.AddRange(direct, linked);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linked.Id, WorkOrderId = deletedLinked.Id });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
Section(feed, NotificationReasons.NoVendor).Items
.Select(item => item.WorkOrders.Single().Number)
.Should().BeEquivalentTo("NV-DEL-DIRECT", "NV-DEL-LINKED");
}
[Fact]
public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency()
{
using var context = NewContext();
var aveta = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "AV-1");
var earlierNoVendor = Open(1, "dispatcher-1", Now.AddHours(5), "NV-EARLY");
var laterNoVendor = Open(1, "dispatcher-1", Now.AddHours(30), "NV-LATE");
context.workOrders.AddRange(aveta, earlierNoVendor, laterNoVendor, Open(1));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
feed.GeneratedAt.Should().Be(Now);
feed.Sections.Select(section => section.Reason).Should().Equal(
NotificationReasons.Unassigned, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing);
feed.Sections.Select(section => section.Severity).Should().Equal(
NotificationSeverities.High, NotificationSeverities.Medium, NotificationSeverities.Medium);
var noVendorNumbers = Section(feed, NotificationReasons.NoVendor).Items
.Select(item => item.WorkOrders.Single().Number);
noVendorNumbers.Should().ContainInOrder("NV-LATE", "NV-EARLY");
}
[Fact]
public void Ordering_BreaksEqualSeverityTiesByReasonOrderAndPutsHigherSeverityFirst()
{
static NotificationItemDto Item(string id, string reason, string severity, int minutes) => new()
{
Id = id,
Reason = reason,
Severity = severity,
Count = 1,
TriggeredAt = Now.AddMinutes(minutes)
};
var sections = NotificationFeedOrdering.Order(new (string, int, IReadOnlyList<NotificationItemDto>)[]
{
(NotificationReasons.AvetaMissing, 1, new[] { Item("a", NotificationReasons.AvetaMissing, NotificationSeverities.Medium, 0) }),
(NotificationReasons.Activity, 2, new[]
{
Item("old-low", NotificationReasons.Activity, NotificationSeverities.Low, -10),
Item("new-low", NotificationReasons.Activity, NotificationSeverities.Low, 10),
Item("high", NotificationReasons.Activity, NotificationSeverities.High, -20)
}),
(NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>()),
(NotificationReasons.NoVendor, 1, new[] { Item("n", NotificationReasons.NoVendor, NotificationSeverities.Medium, 0) })
});
sections.Select(section => section.Reason).Should().Equal(
NotificationReasons.Activity, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing);
sections[0].Items.Select(item => item.Id).Should().Equal("high", "new-low", "old-low");
sections[0].Label.Should().Be("Recent activity");
}
[Fact]
public async Task Dispatcher_OnlySeesTheirOwnWorkOrders()
{
using var context = NewContext();
context.workOrders.AddRange(
AvetaDue(1, "dispatcher-1", Today.AddHours(9), "MINE"),
AvetaDue(1, "dispatcher-2", Today.AddHours(9), "THEIRS"),
Open(1, "dispatcher-2", Now.AddHours(5), "THEIRS-NV"));
await context.SaveChangesAsync();
foreach (var workOrder in context.workOrders.Where(w => w.AvetaRequired).ToList())
AddActiveDispatch(context, workOrder);
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
feed.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.AvetaMissing);
Section(feed, NotificationReasons.AvetaMissing).Items.Single().WorkOrders.Single().Number.Should().Be("MINE");
}
[Fact]
public async Task CrossTenant_AccountUserNeverSeesAnotherAccountsWorkOrders()
{
using var context = NewContext();
var otherAveta = AvetaDue(2, "dispatcher-1", Today.AddHours(9), "OTHER-AV");
var otherNoVendor = Open(2, "dispatcher-1", Now.AddHours(5), "OTHER-NV");
var otherUnassigned = Open(2);
var ownUnassigned = Open(1);
context.workOrders.AddRange(otherAveta, otherNoVendor, otherUnassigned, ownUnassigned);
await context.SaveChangesAsync();
AddActiveDispatch(context, otherAveta);
var vendor = context.Vendors.First();
var overlapping = Open(2, "dispatcher-1", Now.AddHours(1), "OTHER-OVERLAP");
context.workOrders.Add(overlapping);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = overlapping.Id, Status = "Sent", ScheduledDate = otherAveta.ScheduledDate });
await context.SaveChangesAsync();
var admin = await NewService(context).GetFeedAsync(User(1, "Admin"), CancellationToken.None);
var dispatcher = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
admin.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.Unassigned);
Section(admin, NotificationReasons.Unassigned).Count.Should().Be(1);
dispatcher.Sections.Should().BeEmpty();
var orgWide = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None);
orgWide.Sections.Select(section => section.Reason).Should().Contain(new[]
{
NotificationReasons.Unassigned, NotificationReasons.NoVendor,
NotificationReasons.AvetaMissing, NotificationReasons.VendorConflict
});
}
[Fact]
public async Task VendorConflict_KeepsOverlappingAppointmentsAsTheirOwnSection()
{
using var context = NewContext();
var mine = Open(1, "dispatcher-1", Now.AddDays(3), "C-MINE");
var theirs = Open(1, "dispatcher-2", Now.AddDays(3), "C-THEIRS");
context.workOrders.AddRange(mine, theirs);
await context.SaveChangesAsync();
AddActiveDispatch(context, mine);
var vendor = context.Vendors.Single();
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = theirs.Id, Status = "Sent", ScheduledDate = theirs.ScheduledDate });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var unrelated = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-3"), CancellationToken.None);
var section = Section(feed, NotificationReasons.VendorConflict);
section.Label.Should().Be("Vendor Conflict");
section.Count.Should().Be(2);
var item = section.Items.Single();
item.Title.Should().Be("Gateway has overlapping appointments");
item.WorkOrders.Select(workOrder => workOrder.Number).Should().BeEquivalentTo("C-MINE", "C-THEIRS");
unrelated.Sections.Should().BeEmpty();
}
[Fact]
public async Task VendorConflict_CapsByRecencyKeepingNewerOverlaps()
{
using var context = NewContext();
// One vendor whose older cluster produces more overlapping pairs than the cap, plus a
// single newer overlap. Build sweeps a vendor's windows in start order, so the newer pair
// is emitted last and is exactly what an unsorted Take(limit) would drop.
var oldStart = Now.AddDays(3);
var oldOrders = new List<WorkOrder>();
for (var index = 0; index < 11; index++)
oldOrders.Add(Open(1, "dispatcher-1", oldStart, $"OLD-{index}"));
var newerLeft = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-A");
var newerRight = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-B");
context.workOrders.AddRange(oldOrders);
context.workOrders.AddRange(newerLeft, newerRight);
await context.SaveChangesAsync();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
foreach (var workOrder in oldOrders.Append(newerLeft).Append(newerRight))
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledDate });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
var section = Section(feed, NotificationReasons.VendorConflict);
section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit);
section.Items.Should().Contain(item =>
item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-A")
&& item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-B"));
}
[Fact]
public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder()
{
using var context = NewContext();
var total = NotificationFeedService.SectionItemLimit + 5;
for (var index = 0; index < total; index++)
context.workOrders.Add(Open(1, "dispatcher-1", Now.AddHours(1).AddMinutes(index), $"B-{index}"));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var section = Section(feed, NotificationReasons.NoVendor);
section.Count.Should().Be(total);
section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit);
}
[Fact]
public async Task MissingAccountScope_FailsClosed()
{
using var context = NewContext();
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
var act = () => NewService(context).GetFeedAsync(user, CancellationToken.None);
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task UnknownRole_FailsClosed()
{
using var context = NewContext();
var act = () => NewService(context).GetFeedAsync(User(1, "Vendor"), CancellationToken.None);
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Cancellation_ReachesTheFeedQueries()
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => NewService(context).GetFeedAsync(User(1, "Admin"), cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task Controller_MapsForbiddenScopeTo403()
{
using var context = NewContext();
var controller = new NotificationsController(NewService(context))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(1, "Vendor") }
}
};
var result = await controller.GetFeed(CancellationToken.None);
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
[Fact]
public async Task Controller_ReturnsTheFeedForAnAuthorizedUser()
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewService(context))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(1, "Admin") }
}
};
var result = await controller.GetFeed(CancellationToken.None);
var feed = result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeOfType<NotificationFeedDto>().Subject;
feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned);
}
[Fact]
public void Controller_RequiresAnAuthenticatedCaller()
{
typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
}
}

View file

@ -282,6 +282,92 @@ public sealed class UpliftQueueReadTests
item.Status.Should().Be("Pending");
}
[Fact]
public async Task List_MapsDetailModalWorkOrderContext_DispatcherTechnicianAndScheduledDate()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-81", "SITE-N", "Plumbing");
vendor.ContactName = " Tom Tech ";
workOrder.ScheduledDate = new DateTime(2026, 4, 10, 9, 30, 0);
workOrder.AssignTo = "dispatcher-1";
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Dana", LastName = "Ruiz" });
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-81");
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Should().ContainSingle().Subject;
item.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 4, 10, 9, 30, 0));
item.WorkOrderDispatcherName.Should().Be("Dana Ruiz");
item.TechnicianName.Should().Be("Tom Tech");
item.VendorCompanyName.Should().Be("Gateway");
}
[Fact]
public async Task List_DetailModalWorkOrderContext_IsNullForUnassignedUnscheduledWorkOrder()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-82", "SITE-N", "Plumbing");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-82");
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Should().ContainSingle().Subject;
item.WorkOrderScheduledDate.Should().BeNull();
item.WorkOrderDispatcherName.Should().BeNull();
item.TechnicianName.Should().BeNull();
}
[Fact]
public async Task List_DetailModalWorkOrderContext_NeverCrossesAccounts()
{
// Two tenants' work orders sit side by side. Each queue row must carry the
// dispatcher, technician and schedule of its own work order and vendor only;
// another account's work order never supplies them.
using var context = NewContext();
var accountA = new Accounts { Name = "Account A" };
var accountB = new Accounts { Name = "Account B" };
context.AddRange(accountA, accountB);
context.Users.AddRange(
new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" },
new ApplicationUser { Id = "dispatcher-b", FirstName = "Ben", LastName = "Beta" });
await context.SaveChangesAsync();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Vendor A");
vendorA.ContactName = "Tech A";
workOrderA.AccountId = accountA.Id;
workOrderA.AssignTo = "dispatcher-a";
workOrderA.ScheduledDate = new DateTime(2026, 5, 1);
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Roofing", "Vendor B");
vendorB.ContactName = "Tech B";
workOrderB.AccountId = accountB.Id;
workOrderB.AssignTo = "dispatcher-b";
workOrderB.ScheduledDate = new DateTime(2026, 6, 1);
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
context.DispatchUpliftRequests.AddRange(
Request(dispatchA, "Pending", new DateTime(2026, 4, 1)),
Request(dispatchB, "Pending", new DateTime(2026, 4, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var rowA = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderA.Id).Subject;
rowA.WorkOrderDispatcherName.Should().Be("Ann Alpha");
rowA.TechnicianName.Should().Be("Tech A");
rowA.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 5, 1));
var rowB = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderB.Id).Subject;
rowB.WorkOrderDispatcherName.Should().Be("Ben Beta");
rowB.TechnicianName.Should().Be("Tech B");
rowB.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 6, 1));
}
[Fact]
public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
{

View file

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

View file

@ -71,6 +71,7 @@ public class WorkOrderRouteContractTests
"PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}",
"PATCH {id:int}/media/{mediaId:int}",
"PATCH {id:int}/poc",
"POST AddChecklistItem",
"POST AddComment",
"POST AddCommentJson",

View file

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/notifications")]
public class NotificationsController : ControllerBase
{
private readonly INotificationFeedService _feedService;
public NotificationsController(INotificationFeedService feedService)
{
_feedService = feedService;
}
[HttpGet]
public async Task<IActionResult> GetFeed(CancellationToken cancellationToken)
{
try
{
return Ok(await _feedService.GetFeedAsync(User, cancellationToken));
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
}
}
}

View file

@ -24,6 +24,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
private readonly IWorkOrderBoardCreateService _boardCreateService;
private readonly IWorkOrderBoardCancelService _boardCancelService;
private readonly IWorkOrderPocService _pocService;
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
public WorkOrderBoardController(
@ -31,12 +32,14 @@ namespace Api.SeaHavenIndustries.Controllers
IWorkOrderBoardUpdateService boardUpdateService,
IWorkOrderBoardCreateService boardCreateService,
IWorkOrderBoardCancelService boardCancelService,
IWorkOrderPocService pocService,
IWorkOrderAdvancedSearchService advancedSearchService)
{
_workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
_boardCreateService = boardCreateService;
_boardCancelService = boardCancelService;
_pocService = pocService;
_advancedSearchService = advancedSearchService;
}
@ -165,6 +168,56 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
/// <summary>
/// SH-379: replaces the WO-level POC (name, phone, notes) with audit entries.
/// Blank name+phone clears the override so the work order follows the Site.
/// </summary>
[HttpPatch("{id:int}/poc")]
public async Task<IActionResult> UpdateWorkOrderPoc(int id, [FromBody] WorkOrderPocUpdateRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _pocService.UpdatePocAsync(id, request, User, actorId);
return Ok(row);
}
catch (WorkOrderBoardConcurrencyException ex)
{
return Conflict(new WorkOrderBoardConflictDto
{
CurrentState = ex.CurrentState
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("board")]
public async Task<IActionResult> CreateBoardWorkOrder(
[FromBody] WorkOrderBoardCreateRequestDto request,

View file

@ -1,106 +0,0 @@
# Backend Architecture and Quality Rules
This repository uses a feature-oriented service boundary over Entity Framework Core:
```text
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
```
The interfaces are architectural seams, not a requirement to wrap every class or every
EF method. Add an interface when it separates HTTP, business, persistence, or external
I/O responsibilities and enables behavior-focused testing.
## Layer responsibilities
### API controllers
- Parse HTTP input, authorize the caller, invoke a business-service interface, and map
the result to the established HTTP contract.
- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete
data-service implementations.
- Do not contain persistence queries or business workflows.
- Do not expose exception messages to clients. Map known failures explicitly and let the
centralized exception boundary handle unexpected failures.
### Business services
- Own validation, business decisions, orchestration, and transaction intent.
- Depend on interfaces for persistence and external I/O.
- Use `IOptions<T>` for typed configuration. Do not inject `IConfiguration`.
- Keep feature responsibilities cohesive. Split a service when it has independently
changing business reasons, not because it crossed an arbitrary line count.
- Preserve existing API and board-backed behavior unless the accepted requirement
explicitly changes it.
### Data services
- Own EF Core query shape, persistence, batching, and transaction implementation.
- Accept cancellation tokens on new I/O methods and pass them to EF Core.
- Use `AsNoTracking()` for read-only queries.
- Project only required columns for list/read models; include full graphs only when the
caller needs them.
- Page unbounded collections at the database.
- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes
while preserving the workflow's failure and transaction semantics.
- Do not introduce a generic repository over EF Core. Feature data services should expose
intent-revealing operations.
## Performance review
For each changed hot path, document and test:
- input size variables;
- CPU and memory complexity;
- database round trips;
- whether filtering, ordering, and paging execute in SQL;
- whether tracking is necessary;
- whether repeated work can be hoisted out of loops;
- the partial-failure and transaction behavior.
Prefer dictionary or hash-set reconstruction when database results must follow caller
order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory;
repeated `First`/`Single` scans over the result are `O(n²)`.
Do not optimize by weakening correctness. In particular, batching all writes into one
final save can change partial-success behavior, and adding a transaction can change
locking and retry semantics. Those are business decisions, not mechanical cleanup.
## Configuration
Configuration is bound once during composition in
`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive
typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and
`VendorPortalOptions`.
Defaults must preserve current behavior. Startup validation should be introduced only
when every deployed environment is known to provide the required value.
## Enforcement
`ArchitectureTests` enforces mechanical dependency rules:
- controllers cannot inject EF contexts or concrete service/data-service classes;
- business services cannot inject EF contexts or raw `IConfiguration`.
The pull-request quality workflow runs those tests and verifies formatting for changed
C# files. The normal CI workflow builds the solution and runs the full test suite.
Architecture tests are appropriate for dependency direction. Behavior belongs in tests
through public interfaces. Do not add tests merely to prove a linter or analyzer itself
works; test the product behavior the rule protects.
## Review checklist
- Controller depends only on service abstractions.
- Business logic is in a cohesive feature service.
- EF operations are behind a feature data-service abstraction.
- No raw `IConfiguration` in a business service.
- No unbounded load followed by in-memory filtering.
- No query/save loop where a set-based operation preserves semantics.
- Read-only EF queries use no tracking.
- New async I/O accepts and propagates cancellation where the public contract permits.
- Errors do not leak internal exception details.
- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing
records, and relevant failure boundaries.
- Changed behavior has been checked against the authoritative ticket board. Missing board
access blocks readiness and merge claims.

View file

@ -85,6 +85,12 @@ namespace Data.SeaHavenIndustries
public string? WorkOrderNumber { get; set; }
public string? WorkOrderSiteCode { get; set; }
public string? WorkOrderService { get; set; }
// SH-209: detail modal context, resolved from the same effective work order
// and the requesting dispatch's vendor.
public DateTime? WorkOrderScheduledDate { get; set; }
public string? WorkOrderDispatcherFirstName { get; set; }
public string? WorkOrderDispatcherLastName { get; set; }
public string? TechnicianName { get; set; }
public bool WorkOrderClosed { get; set; }
public int AttachmentCount { get; set; }
public string? RequestedByVendorName { get; set; }

115
README.md Normal file
View file

@ -0,0 +1,115 @@
# 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,6 +130,11 @@ Avoid boilerplate: no deployment notes, no validation transcripts, no
"residual-risk" theatre, no AI signatures. The validation story lives in the
check run results and the close-out, not in the PR body.
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
template, whose Summary / Validation / Tests / Notes headings this repository
does not use. The org `callable-pr-policy` workflow hard-codes those four
headings; it is not wired into this repository, and this layout is the reason.
## 9. Merge readiness
Merge requires: exact-head review done; board-backed regression check pass (or

View file

@ -52,6 +52,20 @@ namespace SeaHaven.DataServices.Helpers
c.Notes
})
.FirstOrDefault(),
// SH-379/SH-190: the Site's live primary contact backs the
// follow-the-site POC display when no override or link exists.
SitePoc = w.Locations != null
? w.Locations.Contacts!
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => new
{
Name = ((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim(),
c.PhoneNumber
})
.FirstOrDefault()
: null,
w.LifecycleStatus,
w.LegacyStatus,
w.Status,
@ -116,11 +130,11 @@ namespace SeaHaven.DataServices.Helpers
: null;
var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault();
var pocName = primaryFrozenPoc?.Name
?? (!string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name);
?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name);
var pocPhone = primaryFrozenPoc?.Phone
?? (!string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber);
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
var pocNotes = frozenPoc?.Notes
?? (!string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes);
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
@ -188,10 +202,15 @@ namespace SeaHaven.DataServices.Helpers
w.MediaCount,
frozenSite,
frozenCompany,
frozenPoc);
frozenPoc,
w.WoPocName,
w.WoPocPhone);
}).ToList();
}
private static string? FirstNotBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static T? Deserialize<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))

View file

@ -0,0 +1,226 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Implementation
{
public class NotificationFeedDataService : INotificationFeedDataService
{
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
private const string AssignToAuditField = "AssignTo";
private const string LegacyAssignedToAuditField = "AssignedTo";
private readonly ApplicationDbContext _context;
public NotificationFeedDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<UnassignedWorkOrdersSummary> GetUnassignedSummaryAsync(
int? accountId, CancellationToken cancellationToken)
{
var summary = await OpenWorkOrders(new NotificationFeedScope(accountId, null))
.Where(w => w.AssignTo == null || w.AssignTo == "")
.GroupBy(_ => 1)
.Select(group => new UnassignedWorkOrdersSummary
{
Count = group.Count(),
LatestCreatedAt = group.Max(w => w.CreatedDate)
})
.FirstOrDefaultAsync(cancellationToken);
return summary ?? new UnassignedWorkOrdersSummary();
}
public Task<NotificationCandidatePage> GetAvetaMissingAsync(
NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken)
{
// Same rule as the dashboard's Aveta Pending tile so the two counts agree.
var windowStart = today.ToDateTime(TimeOnly.MinValue);
var windowEnd = today.AddDays(2).ToDateTime(TimeOnly.MinValue);
var candidates = OpenWorkOrders(scope)
.Where(w => w.AvetaRequired
&& w.ScheduledDate >= windowStart
&& w.ScheduledDate < windowEnd
&& !w.workOrderAttachments!.Any(attachment =>
(attachment.IsDeleted == null || attachment.IsDeleted == false)
&& attachment.Category == WorkOrderMediaCategory.Aveta));
return PageAsync(candidates, limit, cancellationToken);
}
public Task<NotificationCandidatePage> GetNoVendorAsync(
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken)
{
// "Has a vendor" matches the vendor reminders: any live non-terminal dispatch that
// points at the work order directly or links it through DispatchWorkOrders. Soft-deleted
// dispatches are ignored so a removed dispatch does not keep the row out of No Vendor.
var candidates = OpenWorkOrders(scope)
.Where(w => w.ScheduledDate.HasValue
&& w.ScheduledDate.Value <= cutoff
&& !_context.Dispatches.Any(d => d.WorkOrderId == w.Id
&& (d.IsDeleted == null || d.IsDeleted == false)
&& !TerminalDispatchStatuses.Contains(d.Status ?? ""))
&& !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id
&& link.Dispatch != null
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")));
return PageAsync(candidates, limit, cancellationToken);
}
public async Task<IReadOnlyList<NotificationAssignmentCandidate>> GetRecentAssignmentsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
{
// Board create and board edits log "AssignTo" with user ids; the legacy assignment endpoint
// logs "AssignedTo" with display names. Only the time and the actor are read, so both count.
var assignmentLogs = _context.WorkOrderAuditLogs.AsNoTracking()
.Where(log => log.FieldName == AssignToAuditField || log.FieldName == LegacyAssignedToAuditField);
// The latest assignment entry of each work order the user holds now.
var candidates =
from log in assignmentLogs
join w in OpenWorkOrders(new NotificationFeedScope(scope.AccountId, scope.UserId))
on log.WorkOrderId equals w.Id
where log.CreatedAt >= since
&& (log.UserId == null || log.UserId != scope.UserId)
&& !assignmentLogs.Any(later => later.WorkOrderId == log.WorkOrderId
&& (later.CreatedAt > log.CreatedAt || (later.CreatedAt == log.CreatedAt && later.Id > log.Id)))
orderby log.CreatedAt descending, w.Id descending
select new NotificationAssignmentCandidate
{
WorkOrderId = w.Id,
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
AssignedAt = log.CreatedAt,
AssignedBy = log.UserId
};
return await candidates.Take(limit).ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<NotificationCommentCandidate>> GetUnansweredCommentsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
{
var userId = scope.UserId;
// SH-217 stores a mention as the token "@[userId:Display Name]" inside the comment text.
var mentionToken = "@[" + userId + ":";
var ownComments = _context.Comments.AsNoTracking()
.Where(own => own.UserId == userId && own.IsDeleted != true);
var candidates =
from c in _context.Comments.AsNoTracking()
join w in ScopedWorkOrders(scope.AccountId) on c.WorkerOrderId equals w.Id
where c.IsDeleted != true
&& c.CreatedDate != null
&& c.CreatedDate >= since
&& (c.UserId == null || c.UserId != userId)
&& (w.AssignTo == userId
|| (c.Commenttext != null && c.Commenttext.Contains(mentionToken))
|| ownComments.Any(own => own.WorkerOrderId == w.Id))
&& !ownComments.Any(own => own.WorkerOrderId == w.Id && own.CreatedDate >= c.CreatedDate)
orderby c.CreatedDate descending, c.Id descending
select new NotificationCommentCandidate
{
CommentId = c.Id,
WorkOrderId = w.Id,
WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber,
AuthorFirstName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : null,
AuthorLastName = c.ApplicationUser != null ? c.ApplicationUser.LastName : null,
Commenter = c.Commenter,
Text = c.Commenttext,
CreatedAt = c.CreatedDate!.Value
};
return await candidates.Take(limit).ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
{
var userId = scope.UserId;
// Same work-order resolution as the uplift reads: the dispatch's own work order,
// otherwise the first work order linked through DispatchWorkOrders.
var candidates =
from u in _context.DispatchUpliftRequests.AsNoTracking()
join d in _context.Dispatches.AsNoTracking() on u.DispatchId equals d.Id
let workOrderId = d.WorkOrderId
?? d.DispatchWorkOrders!.Select(link => (int?)link.WorkOrderId).FirstOrDefault()
join w in ScopedWorkOrders(scope.AccountId) on workOrderId equals (int?)w.Id
where u.IsDeleted != true
&& d.IsDeleted != true
&& u.createdby == userId
&& u.DecidedAt != null
&& u.DecidedAt >= since
&& (u.DecidedByUserId == null || u.DecidedByUserId != userId)
&& DecisionStatuses.Contains(u.Status)
orderby u.DecidedAt descending, u.Id descending
select new NotificationUpliftDecisionCandidate
{
UpliftId = u.Id,
WorkOrderId = w.Id,
WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber,
Status = u.Status,
Amount = u.RequestedNTE,
DecisionNote = u.DecisionNote,
DecidedAt = u.DecidedAt!.Value
};
return await candidates.Take(limit).ToListAsync(cancellationToken);
}
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
return accountId is int id
? WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, id)
: workOrders;
}
private IQueryable<WorkOrder> OpenWorkOrders(NotificationFeedScope scope)
{
var workOrders = ScopedWorkOrders(scope.AccountId);
if (scope.DispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == scope.DispatcherId);
return workOrders.Where(w => w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& (w.LifecycleStatus != null
|| ((w.LegacyStatus ?? w.Status) != "Done"
&& (w.LegacyStatus ?? w.Status) != "Completed"
&& (w.LegacyStatus ?? w.Status) != "Complete"
&& (w.LegacyStatus ?? w.Status) != "Closed"
&& (w.LegacyStatus ?? w.Status) != "Canceled"
&& (w.LegacyStatus ?? w.Status) != "Cancelled")));
}
private static async Task<NotificationCandidatePage> PageAsync(
IQueryable<WorkOrder> candidates, int limit, CancellationToken cancellationToken)
{
var total = await candidates.CountAsync(cancellationToken);
if (total == 0)
return new NotificationCandidatePage();
var items = await candidates
.OrderByDescending(w => w.ScheduledDate)
.ThenByDescending(w => w.Id)
.Take(limit)
.Select(w => new NotificationWorkOrderCandidate
{
Id = w.Id,
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
ScheduledDate = w.ScheduledDate!.Value
})
.ToListAsync(cancellationToken);
return new NotificationCandidatePage { Total = total, Items = items };
}
}
}

View file

@ -79,6 +79,18 @@ namespace SeaHaven.DataServices.Implementation
WorkOrderNumber = x.workOrder != null ? x.workOrder.InternalWONumber : null,
WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null,
WorkOrderService = x.workOrder != null ? x.workOrder.Service : null,
// SH-209: the detail modal reads its work-order context from this row,
// so it never depends on a separate work-order fetch. Technician
// follows the board convention (vendor contact) for the requesting
// dispatch's vendor, the same vendor as VendorCompanyName.
WorkOrderScheduledDate = x.workOrder != null ? x.workOrder.ScheduledDate : null,
WorkOrderDispatcherFirstName = x.workOrder != null && x.workOrder.AssignToUser != null
? x.workOrder.AssignToUser.FirstName
: null,
WorkOrderDispatcherLastName = x.workOrder != null && x.workOrder.AssignToUser != null
? x.workOrder.AssignToUser.LastName
: null,
TechnicianName = x.v != null ? x.v.ContactName : null,
// SH-208: a work order is closed for uplift decisions once its
// lifecycle reaches a terminal state; mirrors the SH-196 revoke guard.
WorkOrderClosed = x.workOrder != null

View file

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

View file

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

View file

@ -0,0 +1,47 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderPocDataService : IWorkOrderPocDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderPocDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
=> _context.workOrders
.Include(workOrder => workOrder.Locations!)
.ThenInclude(location => location.Contacts)
.Include(workOrder => workOrder.WorkOrderContacts!)
.ThenInclude(contact => contact.POC)
.FirstOrDefaultAsync(
w => w.Id == workOrderId
&& w.istemplate != true
&& (w.IsDeleted != true || w.IsDeleted == null)
&& (accountId == null || w.AccountId == accountId),
cancellationToken);
public async Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
int? locationId,
CancellationToken cancellationToken)
{
if (locationId == null)
return Array.Empty<Contacts>();
return await _context.Contacts
.AsNoTracking()
.Where(c => c.LocationId == locationId && c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.ToListAsync(cancellationToken);
}
}
}

View file

@ -0,0 +1,52 @@
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// Live work-order conditions behind the notification feed. Every query applies the
/// base scope (no templates, no deleted rows), the caller's account scope, and the
/// shared open rule (not Completed/Canceled, including legacy terminal statuses).
/// </summary>
public interface INotificationFeedDataService
{
/// <summary>Open work orders with no dispatcher assigned in <paramref name="accountId"/> (null is org-wide).</summary>
Task<UnassignedWorkOrdersSummary> GetUnassignedSummaryAsync(int? accountId, CancellationToken cancellationToken);
/// <summary>
/// Open, Aveta-required work orders scheduled on <paramref name="today"/> or the next day with no
/// Aveta document attached. Items are the <paramref name="limit"/> latest scheduled.
/// </summary>
Task<NotificationCandidatePage> GetAvetaMissingAsync(
NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken);
/// <summary>
/// Open work orders scheduled at or before <paramref name="cutoff"/> with no active vendor dispatch,
/// direct or linked. Items are the <paramref name="limit"/> latest scheduled.
/// </summary>
Task<NotificationCandidatePage> GetNoVendorAsync(
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken);
/// <summary>
/// Open work orders assigned to the user whose latest assignment happened at or after
/// <paramref name="since"/> and was made by someone else, read from the latest AssignTo audit
/// entry (board create writes one too). Returns at most <paramref name="limit"/>, newest first.
/// </summary>
Task<IReadOnlyList<NotificationAssignmentCandidate>> GetRecentAssignmentsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
/// <summary>
/// Comments posted at or after <paramref name="since"/> by someone other than the user, on work orders
/// the user is assigned to, has commented on, or that mention the user, and that the user has not
/// commented after. Returns at most <paramref name="limit"/>, newest first.
/// </summary>
Task<IReadOnlyList<NotificationCommentCandidate>> GetUnansweredCommentsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
/// <summary>
/// Approve, reject and revoke decisions made at or after <paramref name="since"/> by someone else on
/// uplifts the user requested. Returns at most <paramref name="limit"/>, newest first.
/// </summary>
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
}
}

View file

@ -8,6 +8,8 @@ namespace SeaHaven.DataServices.Interfaces
Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken);
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken);
/// <summary>Same windows limited to work orders of <paramref name="accountId"/>; null is org-wide.</summary>
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(int? accountId, CancellationToken cancellationToken);
Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken);
Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken);

View file

@ -0,0 +1,28 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// SH-379: data access for the work-order-level POC override.
/// </summary>
public interface IWorkOrderPocDataService
{
/// <summary>
/// SH-221 account-scoped tracked load for POC updates. Includes the site's
/// contacts and the linked WorkOrderContacts POC. Null when the work order
/// does not exist or is outside the caller's account scope.
/// </summary>
Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
/// <summary>
/// The site's live contacts for a location ordered by SiteContactOrder then Id.
/// Deleted contacts are excluded. Empty when the location has none.
/// </summary>
Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
int? locationId,
CancellationToken cancellationToken);
}
}

View file

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

View file

@ -0,0 +1,66 @@
namespace SeaHaven.DataServices.Models;
/// <summary>
/// Server-derived feed scope. <see cref="AccountId"/> null is org-wide;
/// <see cref="DispatcherId"/> null means every dispatcher's work orders.
/// </summary>
public sealed record NotificationFeedScope(int? AccountId, string? DispatcherId);
public sealed class UnassignedWorkOrdersSummary
{
public int Count { get; init; }
public DateTime? LatestCreatedAt { get; init; }
}
public sealed class NotificationWorkOrderCandidate
{
public int Id { get; init; }
public string? Number { get; init; }
public DateTime ScheduledDate { get; init; }
}
/// <summary>A bounded slice of candidates plus the unbounded total they were taken from.</summary>
public sealed class NotificationCandidatePage
{
public int Total { get; init; }
public IReadOnlyList<NotificationWorkOrderCandidate> Items { get; init; } = Array.Empty<NotificationWorkOrderCandidate>();
}
/// <summary>
/// Server-derived scope for items addressed to one person: <see cref="UserId"/> is the signed-in
/// user from claims and <see cref="AccountId"/> null is org-wide.
/// </summary>
public sealed record NotificationPersonalScope(int? AccountId, string UserId);
/// <summary>An open work order assigned to the user, with when and by whom it was last assigned.</summary>
public sealed class NotificationAssignmentCandidate
{
public int WorkOrderId { get; init; }
public string? Number { get; init; }
public DateTime AssignedAt { get; init; }
public string? AssignedBy { get; init; }
}
public sealed class NotificationCommentCandidate
{
public int CommentId { get; init; }
public int WorkOrderId { get; init; }
public string? WorkOrderNumber { get; init; }
public string? AuthorFirstName { get; init; }
public string? AuthorLastName { get; init; }
/// <summary>Free-text author for comments with no user (email sync, vendor portal).</summary>
public string? Commenter { get; init; }
public string? Text { get; init; }
public DateTime CreatedAt { get; init; }
}
public sealed class NotificationUpliftDecisionCandidate
{
public int UpliftId { get; init; }
public int WorkOrderId { get; init; }
public string? WorkOrderNumber { get; init; }
public string Status { get; init; } = "";
public decimal Amount { get; init; }
public string? DecisionNote { get; init; }
public DateTime DecidedAt { get; init; }
}

View file

@ -7,6 +7,7 @@ public sealed class ScheduledDispatchReadModel
public string? VendorName { get; set; }
public int WorkOrderId { get; set; }
public string? WorkOrderNumber { get; set; }
public string? AssignTo { get; set; }
public DateTime Start { get; set; }
public DateTime End { get; set; }
}

View file

@ -0,0 +1,134 @@
namespace SeaHaven.Services.DTOs
{
/// <summary>
/// Feed reasons in their fixed tie-break order. Every reason the Notification Center
/// can show is listed here so a new producer only adds items, never a contract change.
/// </summary>
public static class NotificationReasons
{
public const string SeverityCritical = "severity-critical";
public const string Sla = "sla";
public const string Mentions = "mentions";
public const string Unassigned = "unassigned";
public const string DueSoon = "due-soon";
public const string NoVendor = "no-vendor";
public const string AvetaMissing = "aveta-missing";
public const string Activity = "activity";
public const string VendorConflict = "vendor-conflict";
public static readonly IReadOnlyList<string> Order = new[]
{
SeverityCritical, Sla, Mentions, Unassigned, DueSoon, NoVendor, AvetaMissing, Activity, VendorConflict
};
public static readonly IReadOnlyDictionary<string, string> Labels = new Dictionary<string, string>
{
[SeverityCritical] = "Critical severity",
[Sla] = "SLA at Risk",
[Mentions] = "Mentions",
[Unassigned] = "Unassigned",
[DueSoon] = "Due Soon",
[NoVendor] = "No Vendor",
[AvetaMissing] = "Aveta Missing",
[Activity] = "Recent activity",
[VendorConflict] = "Vendor Conflict"
};
}
public static class NotificationSeverities
{
public const string Critical = "Critical";
public const string High = "High";
public const string Medium = "Medium";
public const string Low = "Low";
public static int Rank(string severity) => severity switch
{
Critical => 3,
High => 2,
Medium => 1,
_ => 0
};
}
/// <summary>
/// How a row leaves the feed. <see cref="Dismissable"/> rows are hidden client-side for the
/// session; <see cref="Acknowledge"/> rows (a missed response deadline) are excluded from
/// dismiss and dismiss-all and leave only through their own acknowledge action.
/// </summary>
public static class NotificationRowTypes
{
public const string Dismissable = "dismissable";
public const string Acknowledge = "acknowledge";
}
public static class NotificationTargetKinds
{
public const string WorkOrder = "workOrder";
public const string Queue = "queue";
}
/// <summary>Named work-order queues a grouped item opens.</summary>
public static class NotificationQueues
{
public const string Unassigned = "unassigned";
/// <summary>The signed-in user's own open work orders.</summary>
public const string AssignedToMe = "assigned-to-me";
}
/// <summary>Work-order detail tabs a single-work-order item can open on.</summary>
public static class NotificationWorkOrderTabs
{
public const string Info = "info";
public const string Comments = "comments";
public const string Extras = "extras";
public const string Uplifts = "uplifts";
}
public sealed class NotificationTargetDto
{
public string Kind { get; init; } = NotificationTargetKinds.WorkOrder;
public int? WorkOrderId { get; init; }
public string? Tab { get; init; }
public string? Queue { get; init; }
}
public sealed class NotificationWorkOrderRefDto
{
public int Id { get; init; }
public string? Number { get; init; }
}
public sealed class NotificationItemDto
{
/// <summary>Stable across reads while the condition holds; the client dismisses by it.</summary>
public string Id { get; init; } = "";
public string Reason { get; init; } = "";
public string Severity { get; init; } = NotificationSeverities.Low;
public string RowType { get; init; } = NotificationRowTypes.Dismissable;
public string Title { get; init; } = "";
/// <summary>Work orders this item represents.</summary>
public int Count { get; init; }
/// <summary>When the condition started; drives recency ordering.</summary>
public DateTime TriggeredAt { get; init; }
public NotificationTargetDto Target { get; init; } = new();
public IReadOnlyList<NotificationWorkOrderRefDto> WorkOrders { get; init; } = Array.Empty<NotificationWorkOrderRefDto>();
}
public sealed class NotificationSectionDto
{
public string Reason { get; init; } = "";
public string Label { get; init; } = "";
/// <summary>Highest item severity in the section.</summary>
public string Severity { get; init; } = NotificationSeverities.Low;
/// <summary>Work orders the whole section represents, including items beyond the returned slice.</summary>
public int Count { get; init; }
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
}
public sealed class NotificationFeedDto
{
public DateTime GeneratedAt { get; init; }
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
}
}

View file

@ -12,6 +12,10 @@ namespace SeaHaven.Services.DTOs
public string? WorkOrderNumber { get; set; }
public string? WorkOrderSite { get; set; }
public string? WorkOrderService { get; set; }
// SH-209: detail modal work-order context.
public DateTime? WorkOrderScheduledDate { get; set; }
public string? WorkOrderDispatcherName { get; set; }
public string? TechnicianName { get; set; }
public string? RequestedByName { get; set; }
public string? DecidedByName { get; set; }
public decimal? CurrentNTE { get; set; }

View file

@ -92,6 +92,8 @@ namespace SeaHaven.Services.DTOs
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
/// <summary>SH-190: a manual WO-level POC override is present (locked away from the Site).</summary>
public bool PocCustomized { get; set; }
public List<WorkOrderAdditionalContactDto>? AdditionalContacts { get; set; }
public WorkOrderFrozenSiteDto? FrozenSite { get; set; }
public WorkOrderFrozenCompanyDto? FrozenCompany { get; set; }

View file

@ -58,6 +58,20 @@ namespace SeaHaven.Services.DTOs
public int? Severity { get; set; }
}
/// <summary>
/// SH-379: replaces the work-order-level POC override (PocName/PocPhone/PocNotes).
/// Blank name and phone clear the override so the work order follows the Site's
/// live contact again. An edit equal to a live Site contact does not lock (SH-190).
/// </summary>
public class WorkOrderPocUpdateRequestDto
{
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
/// <summary>Base64 row version, same concurrency token as the board patch.</summary>
public string? WorkOrderVersion { get; set; }
}
/// <summary>Advanced search / filter query for the board.</summary>
public class WorkOrderAdvancedSearchQueryDto
{

View file

@ -0,0 +1,220 @@
using System.Globalization;
using System.Text.RegularExpressions;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Builds the feed items addressed to one person: new assignments, comments, mentions and
/// uplift decisions. Copy and severities follow the prototype's Notification Center
/// (new-assigned Low, new-comment Medium, mention High).
/// </summary>
public static class NotificationActivityItems
{
/// <summary>Assignments by one person this close together count as one action.</summary>
public static readonly TimeSpan AssignmentActionGap = TimeSpan.FromMinutes(2);
private const int PreviewLength = 80;
// SH-217 mention token: "@[userId:Display Name]".
private static readonly Regex MentionToken = new(@"@\[([^:\]]+):([^\]]+)\]", RegexOptions.Compiled);
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly CultureInfo Money = CultureInfo.GetCultureInfo("en-US");
public static bool MentionsUser(string? text, string userId)
=> !string.IsNullOrEmpty(text)
&& MentionToken.Matches(text).Any(match => match.Groups[1].Value == userId);
/// <summary>One High item per comment that mentions the user, opening the work order's Comments tab.</summary>
public static IReadOnlyList<NotificationItemDto> Mentions(
IEnumerable<NotificationCommentCandidate> comments, string userId)
{
return comments
.Where(comment => MentionsUser(comment.Text, userId))
.Select(comment => new NotificationItemDto
{
Id = $"mention-{comment.CommentId}",
Reason = NotificationReasons.Mentions,
Severity = NotificationSeverities.High,
Title = $"{AuthorName(comment)} mentioned you on {WorkOrderLabel(comment.WorkOrderId, comment.WorkOrderNumber)}",
Count = 1,
TriggeredAt = Utc(comment.CreatedAt),
Target = WorkOrderTarget(comment.WorkOrderId, NotificationWorkOrderTabs.Comments),
WorkOrders = WorkOrderRefs(comment.WorkOrderId, comment.WorkOrderNumber)
})
.ToList();
}
/// <summary>
/// One Medium item per work order for the comments that do not mention the user; a comment
/// that mentions them is a Mention item instead, never both. <c>TriggeredAt</c> is the
/// earliest comment in the group, so the Comments tab can highlight the group from it.
/// </summary>
public static IReadOnlyList<NotificationItemDto> Comments(
IEnumerable<NotificationCommentCandidate> comments, string userId)
{
return comments
.Where(comment => !MentionsUser(comment.Text, userId))
.GroupBy(comment => comment.WorkOrderId)
.Select(group =>
{
var ordered = group.OrderBy(c => c.CreatedAt).ThenBy(c => c.CommentId).ToList();
var first = ordered[0];
var latest = ordered[^1];
var label = WorkOrderLabel(first.WorkOrderId, first.WorkOrderNumber);
var preview = Preview(first.Text);
var title = ordered.Count > 1
? $"{ordered.Count} new comments on {label}"
: preview.Length == 0
? $"{AuthorName(first)} commented on {label}"
: $"{AuthorName(first)} commented on {label}: “{preview}”";
return new NotificationItemDto
{
// The latest comment is part of the id so a new comment shows again after a dismiss.
Id = $"comments-{first.WorkOrderId}-{latest.CommentId}",
Reason = NotificationReasons.Activity,
Severity = NotificationSeverities.Medium,
Title = title,
Count = 1,
TriggeredAt = Utc(first.CreatedAt),
Target = WorkOrderTarget(first.WorkOrderId, NotificationWorkOrderTabs.Comments),
WorkOrders = WorkOrderRefs(first.WorkOrderId, first.WorkOrderNumber)
};
})
.ToList();
}
/// <summary>
/// Low items for new assignments. Assignments made by the same person within
/// <see cref="AssignmentActionGap"/> of each other are one action: a single work order opens
/// directly, several become one item that opens the user's own queue.
/// </summary>
public static IReadOnlyList<NotificationItemDto> Assignments(IEnumerable<NotificationAssignmentCandidate> assignments)
{
var items = new List<NotificationItemDto>();
foreach (var byActor in assignments.GroupBy(a => a.AssignedBy ?? "", StringComparer.Ordinal))
{
var action = new List<NotificationAssignmentCandidate>();
foreach (var assignment in byActor.OrderBy(a => a.AssignedAt).ThenBy(a => a.WorkOrderId))
{
if (action.Count > 0 && assignment.AssignedAt - action[^1].AssignedAt > AssignmentActionGap)
{
items.Add(AssignmentItem(action));
action = new List<NotificationAssignmentCandidate>();
}
action.Add(assignment);
}
if (action.Count > 0)
items.Add(AssignmentItem(action));
}
return items;
}
/// <summary>
/// One item per decision on an uplift the user requested: Medium when approved, High when
/// rejected or revoked because the requester may need to act. Opens the work order's uplifts.
/// </summary>
public static IReadOnlyList<NotificationItemDto> UpliftDecisions(IEnumerable<NotificationUpliftDecisionCandidate> decisions)
{
return decisions.Select(decision =>
{
var outcome = decision.Status switch
{
"Approved" => "approved",
"Revoked" => "revoked",
_ => "rejected"
};
var reason = outcome == "approved" ? "" : Preview(decision.DecisionNote);
var title = $"Your {decision.Amount.ToString("C2", Money)} uplift on "
+ $"{WorkOrderLabel(decision.WorkOrderId, decision.WorkOrderNumber)} was {outcome}";
return new NotificationItemDto
{
Id = $"uplift-{outcome}-{decision.UpliftId}",
Reason = NotificationReasons.Activity,
Severity = outcome == "approved" ? NotificationSeverities.Medium : NotificationSeverities.High,
Title = reason.Length == 0 ? title : $"{title}: “{reason}”",
Count = 1,
TriggeredAt = Utc(decision.DecidedAt),
Target = WorkOrderTarget(decision.WorkOrderId, NotificationWorkOrderTabs.Uplifts),
WorkOrders = WorkOrderRefs(decision.WorkOrderId, decision.WorkOrderNumber)
};
}).ToList();
}
public static string WorkOrderLabel(int id, string? number)
=> $"WO #{(string.IsNullOrWhiteSpace(number) ? id.ToString(CultureInfo.InvariantCulture) : number)}";
/// <summary>Comment or reason text on one line, mention tokens shown as "@Name", cut to 80 characters.</summary>
public static string Preview(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return "";
var display = MentionToken.Replace(text, match => "@" + match.Groups[2].Value.Trim());
var singleLine = Whitespace.Replace(display, " ").Trim();
return singleLine.Length <= PreviewLength
? singleLine
: singleLine[..(PreviewLength - 1)].TrimEnd() + "…";
}
private static NotificationItemDto AssignmentItem(IReadOnlyList<NotificationAssignmentCandidate> action)
{
var first = action[0];
if (action.Count == 1)
{
return new NotificationItemDto
{
Id = $"new-assigned-{first.WorkOrderId}",
Reason = NotificationReasons.Activity,
Severity = NotificationSeverities.Low,
Title = $"{WorkOrderLabel(first.WorkOrderId, first.Number)} was assigned to you",
Count = 1,
TriggeredAt = Utc(first.AssignedAt),
Target = WorkOrderTarget(first.WorkOrderId, NotificationWorkOrderTabs.Info),
WorkOrders = WorkOrderRefs(first.WorkOrderId, first.Number)
};
}
return new NotificationItemDto
{
// Keyed on the action's first work order so the id holds while the group is unchanged.
Id = $"new-assigned-group-{first.WorkOrderId}",
Reason = NotificationReasons.Activity,
Severity = NotificationSeverities.Low,
Title = $"{action.Count} new work orders assigned to you",
Count = action.Count,
TriggeredAt = Utc(first.AssignedAt),
Target = new NotificationTargetDto
{
Kind = NotificationTargetKinds.Queue,
Queue = NotificationQueues.AssignedToMe
},
WorkOrders = action
.Select(a => new NotificationWorkOrderRefDto { Id = a.WorkOrderId, Number = a.Number })
.ToList()
};
}
private static string AuthorName(NotificationCommentCandidate comment)
{
var name = $"{comment.AuthorFirstName ?? ""} {comment.AuthorLastName ?? ""}".Trim();
if (name.Length > 0)
return name;
return string.IsNullOrWhiteSpace(comment.Commenter) ? "Someone" : comment.Commenter.Trim();
}
private static NotificationTargetDto WorkOrderTarget(int workOrderId, string tab) => new()
{
Kind = NotificationTargetKinds.WorkOrder,
WorkOrderId = workOrderId,
Tab = tab
};
private static IReadOnlyList<NotificationWorkOrderRefDto> WorkOrderRefs(int id, string? number)
=> new[] { new NotificationWorkOrderRefDto { Id = id, Number = number } };
// Stored times are UTC; marking them so keeps the "Z" on the wire and lets the client
// compare them with comment times exactly.
private static DateTime Utc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
}

View file

@ -0,0 +1,57 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Helpers
{
public static class NotificationFeedOrdering
{
/// <summary>
/// Drops empty sections, orders items by severity then recency, and orders sections by
/// their highest severity with the fixed reason order breaking ties.
/// </summary>
public static IReadOnlyList<NotificationSectionDto> Order(
IEnumerable<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)> sections)
{
return sections
.Where(section => section.Items.Count > 0)
.Select(section =>
{
var items = section.Items
.OrderByDescending(item => NotificationSeverities.Rank(item.Severity))
.ThenByDescending(item => item.TriggeredAt)
.ThenBy(item => item.Id, StringComparer.Ordinal)
.ToList();
return new NotificationSectionDto
{
Reason = section.Reason,
Label = NotificationReasons.Labels[section.Reason],
Severity = items[0].Severity,
Count = section.Count,
Items = items
};
})
.OrderByDescending(section => NotificationSeverities.Rank(section.Severity))
.ThenBy(section => ReasonIndex(section.Reason))
.ToList();
}
private static int ReasonIndex(string reason)
{
for (var index = 0; index < NotificationReasons.Order.Count; index++)
{
if (NotificationReasons.Order[index] == reason)
return index;
}
return NotificationReasons.Order.Count;
}
}
public static class NotificationBusinessTime
{
/// <summary>Business-calendar date of <paramref name="utcNow"/> in the dashboard's time zone.</summary>
public static DateOnly Today(DateTime utcNow)
{
var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId);
return DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(utcNow, timezone));
}
}
}

View file

@ -0,0 +1,34 @@
using SeaHaven.DataServices.Models;
namespace SeaHaven.Services.Helpers
{
public static class VendorConflictPairs
{
// Group by vendor so cross-vendor intervals are never compared, then sweep the
// start-sorted windows within a vendor. Every overlapping pair is still emitted;
// output is O(n log n + p) where p is the number of emitted pairs, so the worst
// case stays quadratic only when a single vendor has many mutually-overlapping
// windows, which is inherent to enumerating all such pairs.
public static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> Build(
IReadOnlyList<ScheduledDispatchReadModel> dispatches)
{
foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId))
{
var sorted = group.OrderBy(dispatch => dispatch.Start).ToList();
for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++)
{
var left = sorted[leftIndex];
for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++)
{
var right = sorted[rightIndex];
// Sorted by start: once a window starts at or after left's end, none that follow can overlap it.
if (right.Start >= left.End)
break;
if (left.WorkOrderId != right.WorkOrderId)
yield return (left, right);
}
}
}
}
}
}

View file

@ -17,10 +17,14 @@ namespace SeaHaven.Services.Helpers
var contact = workOrder.WorkOrderContacts?
.OrderBy(item => item.Id)
.FirstOrDefault();
// SH-379/SH-190: a work order without an override or link freezes the
// Site's live primary contact, matching the board display precedence.
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(location?.Contacts);
var pocName = FirstNotBlank(
workOrder.PocName,
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName));
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber);
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName),
WorkOrderPocSiteContact.DisplayName(sitePrimary));
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber);
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes);
var contacts = new List<WorkOrderFrozenPocContact>();

View file

@ -32,17 +32,18 @@ namespace SeaHaven.Services.Helpers
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
};
// A browser fills the multipart part's Content-Type from File.type, which mobile
// browsers leave empty (or the client sends octet-stream) when the OS cannot
// classify a picked file. Only then is the type resolved from the extension; the
// category rule, extension pairing, and magic-byte signature still decide.
private static readonly HashSet<string> UndeterminedContentTypes =
new(StringComparer.OrdinalIgnoreCase) { string.Empty, "application/octet-stream" };
// The multipart part's Content-Type comes from the browser's File.type, which is
// unreliable on mobile: it is left empty or sent as application/octet-stream when the
// OS cannot classify a picked file, and is sometimes a foreign-but-plausible video
// type the OS attaches to a supported container (e.g. video/3gpp for an .mp4,
// video/x-quicktime for a .mov). An allowlisted declared type stays authoritative and
// pairs against its own extension; anything else falls back to the extension. The
// extension pairing and magic-byte signature below still decide, so this never widens
// the accepted set of files.
private static string? ResolveContentType(string declaredType, string extension)
{
if (!UndeterminedContentTypes.Contains(declaredType))
return AllowedContentTypes.Contains(declaredType) ? declaredType : null;
if (AllowedContentTypes.Contains(declaredType))
return declaredType;
foreach (var (contentType, extensions) in ExtensionsByContentType)
{

View file

@ -0,0 +1,51 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// SH-379 / SH-190: single resolution for "the Site's live contact" used by the
/// manual POC override. The same rules back the board projection fallback, the
/// completion snapshot fallback, the create-time baseline, and the
/// "changing away from the contact the work order follows" lock comparison.
/// </summary>
public static class WorkOrderPocSiteContact
{
/// <summary>
/// The Site's primary live contact: first non-deleted contact ordered by
/// SiteContactOrder then Id. Null when the site has no live contacts.
/// </summary>
public static Contacts? ResolvePrimary(IEnumerable<Contacts>? contacts)
=> contacts?
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.FirstOrDefault();
/// <summary>Display name matching the board projection: trimmed First + Last.</summary>
public static string? DisplayName(Contacts? contact)
=> contact == null
? null
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
/// <summary>
/// SH-190: true when a manual POC (trimmed name + phone) equals the single
/// contact the work order currently follows — the linked WorkOrderContacts
/// POC, or else the Site primary. Such an edit is not a change away from
/// that contact, so it must not lock or store an override. Comparing against
/// only the followed contact (rather than any live Site contact) means an
/// edit to a different live contact is still stored, since the work order
/// only ever displays the one it follows.
/// </summary>
public static bool Matches(string? name, string? phone, Contacts? contact)
{
if (contact == null)
return false;
return string.Equals(DisplayName(contact), Normalize(name), StringComparison.Ordinal)
&& string.Equals(Normalize(contact.PhoneNumber), Normalize(phone), StringComparison.Ordinal);
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View file

@ -0,0 +1,236 @@
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class NotificationFeedService : INotificationFeedService
{
/// <summary>Most items returned per per-work-order section; section counts stay exact.</summary>
public const int SectionItemLimit = 50;
/// <summary>How long an assignment, comment, mention or uplift decision stays in the feed.</summary>
public static readonly TimeSpan RecentActivityWindow = TimeSpan.FromDays(7);
/// <summary>Most source rows read per personal event type before grouping.</summary>
public const int PersonalCandidateLimit = 500;
private static readonly TimeSpan NoVendorLeadTime = TimeSpan.FromHours(48);
private readonly INotificationFeedDataService _data;
private readonly IVendorOperationsDataService _vendorData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly TimeProvider _timeProvider;
public NotificationFeedService(
INotificationFeedDataService data,
IVendorOperationsDataService vendorData,
IWorkOrderAccountResolver accountResolver,
TimeProvider timeProvider)
{
_data = data;
_vendorData = vendorData;
_accountResolver = accountResolver;
_timeProvider = timeProvider;
}
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
var scope = new NotificationFeedScope(accountId, dispatcherId);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
if (seesUnassigned)
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
sections.Add(await AvetaMissingAsync(scope, now, cancellationToken));
sections.Add(await VendorConflictsAsync(scope, cancellationToken));
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (!string.IsNullOrWhiteSpace(userId))
sections.AddRange(await PersonalSectionsAsync(new NotificationPersonalScope(accountId, userId), now, cancellationToken));
return new NotificationFeedDto
{
GeneratedAt = now,
Sections = NotificationFeedOrdering.Order(sections)
};
}
// Dispatchers see their own work orders. Roles that already see every dispatcher's
// work on the dashboard see the whole account, including work nobody owns yet.
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
{
if (user.IsInRole("Dispatcher"))
{
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return (dispatcherId, false);
}
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
return (null, true);
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
}
// Items addressed to the signed-in user whatever their role: assignments to them, comments on
// work they take part in, mentions of them, and decisions on uplifts they requested. The
// account scope still applies; the dispatcher scope does not, because a mention or an uplift
// request can concern a work order owned by another dispatcher.
private async Task<IReadOnlyList<(string, int, IReadOnlyList<NotificationItemDto>)>> PersonalSectionsAsync(
NotificationPersonalScope scope, DateTime now, CancellationToken cancellationToken)
{
var since = now.Subtract(RecentActivityWindow);
var comments = await _data.GetUnansweredCommentsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
var assignments = await _data.GetRecentAssignmentsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
var decisions = await _data.GetUpliftDecisionsAsync(scope, since, PersonalCandidateLimit, cancellationToken);
var mentions = NotificationActivityItems.Mentions(comments, scope.UserId);
var activity = NotificationActivityItems.Comments(comments, scope.UserId)
.Concat(NotificationActivityItems.Assignments(assignments))
.Concat(NotificationActivityItems.UpliftDecisions(decisions))
.ToList();
return new[]
{
BoundedSection(NotificationReasons.Mentions, mentions),
BoundedSection(NotificationReasons.Activity, activity)
};
}
// Counts every work order the section covers; returns the most severe, most recent items.
private static (string, int, IReadOnlyList<NotificationItemDto>) BoundedSection(
string reason, IReadOnlyList<NotificationItemDto> items)
{
var workOrderCount = items.SelectMany(item => item.WorkOrders).Select(w => w.Id).Distinct().Count();
var bounded = items
.OrderByDescending(item => NotificationSeverities.Rank(item.Severity))
.ThenByDescending(item => item.TriggeredAt)
.ThenBy(item => item.Id, StringComparer.Ordinal)
.Take(SectionItemLimit)
.ToList();
return (reason, workOrderCount, bounded);
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> UnassignedAsync(
int? accountId, DateTime now, CancellationToken cancellationToken)
{
var summary = await _data.GetUnassignedSummaryAsync(accountId, cancellationToken);
if (summary.Count == 0)
return (NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>());
var item = new NotificationItemDto
{
Id = NotificationReasons.Unassigned,
Reason = NotificationReasons.Unassigned,
Severity = NotificationSeverities.High,
Title = summary.Count == 1
? "1 work order is unassigned"
: $"{summary.Count} work orders are unassigned",
Count = summary.Count,
TriggeredAt = summary.LatestCreatedAt ?? now,
Target = new NotificationTargetDto
{
Kind = NotificationTargetKinds.Queue,
Queue = NotificationQueues.Unassigned
}
};
return (NotificationReasons.Unassigned, summary.Count, new[] { item });
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> NoVendorAsync(
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
{
var page = await _data.GetNoVendorAsync(scope, now.Add(NoVendorLeadTime), SectionItemLimit, cancellationToken);
var items = page.Items.Select(candidate => new NotificationItemDto
{
Id = $"no-vendor-{candidate.Id}",
Reason = NotificationReasons.NoVendor,
Severity = NotificationSeverities.Medium,
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} starts within 48h with no vendor assigned",
Count = 1,
TriggeredAt = candidate.ScheduledDate.Subtract(NoVendorLeadTime),
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
}).ToList();
return (NotificationReasons.NoVendor, page.Total, items);
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> AvetaMissingAsync(
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
{
var today = NotificationBusinessTime.Today(now);
var page = await _data.GetAvetaMissingAsync(scope, today, SectionItemLimit, cancellationToken);
var items = page.Items.Select(candidate => new NotificationItemDto
{
Id = $"aveta-missing-{candidate.Id}",
Reason = NotificationReasons.AvetaMissing,
Severity = NotificationSeverities.Medium,
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} is missing the Aveta document",
Count = 1,
// The one-day window opens the day before the scheduled date.
TriggeredAt = candidate.ScheduledDate.Date.AddDays(-1),
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Extras),
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
}).ToList();
return (NotificationReasons.AvetaMissing, page.Total, items);
}
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> VendorConflictsAsync(
NotificationFeedScope scope, CancellationToken cancellationToken)
{
var dispatches = await _vendorData.GetActiveScheduledDispatchesAsync(scope.AccountId, cancellationToken);
var pairs = VendorConflictPairs.Build(dispatches)
.Where(pair => scope.DispatcherId == null
|| pair.Left.AssignTo == scope.DispatcherId
|| pair.Right.AssignTo == scope.DispatcherId)
.ToList();
var workOrderCount = pairs
.SelectMany(pair => new[] { pair.Left.WorkOrderId, pair.Right.WorkOrderId })
.Distinct()
.Count();
// Cap by recency like the other sections: keep the newest overlaps so a section that
// exceeds the limit never drops a more recent conflict in favour of an older one.
var items = pairs
.OrderByDescending(pair => pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start)
.ThenByDescending(pair => pair.Left.Id)
.ThenByDescending(pair => pair.Right.Id)
.Take(SectionItemLimit)
.Select(pair => new NotificationItemDto
{
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
Reason = NotificationReasons.VendorConflict,
Severity = NotificationSeverities.Medium,
Title = $"{(string.IsNullOrWhiteSpace(pair.Left.VendorName) ? "Vendor" : pair.Left.VendorName)} has overlapping appointments",
Count = 2,
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
Target = WorkOrderTarget(pair.Left.WorkOrderId, NotificationWorkOrderTabs.Info),
WorkOrders = new[]
{
new NotificationWorkOrderRefDto { Id = pair.Left.WorkOrderId, Number = pair.Left.WorkOrderNumber },
new NotificationWorkOrderRefDto { Id = pair.Right.WorkOrderId, Number = pair.Right.WorkOrderNumber }
}
}).ToList();
return (NotificationReasons.VendorConflict, workOrderCount, items);
}
private static NotificationTargetDto WorkOrderTarget(int workOrderId, string tab) => new()
{
Kind = NotificationTargetKinds.WorkOrder,
WorkOrderId = workOrderId,
Tab = tab
};
private static string WorkOrderLabel(int id, string? number)
=> NotificationActivityItems.WorkOrderLabel(id, number);
}
}

View file

@ -49,6 +49,9 @@ namespace SeaHaven.Services.Implementation
WorkOrderNumber = r.WorkOrderNumber,
WorkOrderSite = r.WorkOrderSiteCode,
WorkOrderService = r.WorkOrderService,
WorkOrderScheduledDate = r.WorkOrderScheduledDate,
WorkOrderDispatcherName = ResolveRequestedByName(null, r.WorkOrderDispatcherFirstName, r.WorkOrderDispatcherLastName),
TechnicianName = string.IsNullOrWhiteSpace(r.TechnicianName) ? null : r.TechnicianName.Trim(),
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
CurrentNTE = r.CurrentNTE,

View file

@ -3,6 +3,7 @@ using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -433,32 +434,9 @@ namespace SeaHaven.Services.Implementation
.ToList();
}
// Group by vendor so cross-vendor intervals are never compared, then sweep the
// start-sorted windows within a vendor. Every overlapping pair is still emitted;
// output is O(n log n + p) where p is the number of emitted pairs, so the worst
// case stays quadratic only when a single vendor has many mutually-overlapping
// windows, which is inherent to enumerating all such pairs.
private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs(
IReadOnlyList<ScheduledDispatchReadModel> dispatches)
{
foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId))
{
var sorted = group.OrderBy(dispatch => dispatch.Start).ToList();
for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++)
{
var left = sorted[leftIndex];
for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++)
{
var right = sorted[rightIndex];
// Sorted by start: once a window starts at or after left's end, none that follow can overlap it.
if (right.Start >= left.End)
break;
if (left.WorkOrderId != right.WorkOrderId)
yield return (left, right);
}
}
}
}
=> VendorConflictPairs.Build(dispatches);
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
=> leftStart < rightEnd && rightStart < leftEnd;

View file

@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderBoardCreateValidation _validator;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IWorkOrderPocDataService _pocData;
private readonly IServicesRegistryService? _servicesRegistryService;
public WorkOrderBoardCreateService(
@ -29,6 +30,7 @@ namespace SeaHaven.Services.Implementation
IWorkOrderAuditService auditService,
IWorkOrderBoardCreateValidation validator,
IWorkOrderAccountResolver accountResolver,
IWorkOrderPocDataService pocData,
IServicesRegistryService? servicesRegistryService = null)
{
_boardDataService = boardDataService;
@ -37,6 +39,7 @@ namespace SeaHaven.Services.Implementation
_auditService = auditService;
_validator = validator;
_accountResolver = accountResolver;
_pocData = pocData;
_servicesRegistryService = servicesRegistryService;
}
@ -64,6 +67,25 @@ namespace SeaHaven.Services.Implementation
var pocName = TrimOrNull(request.PocName);
var pocPhone = TrimOrNull(request.PocPhone);
var pocNotes = TrimOrNull(request.PocNotes);
// SH-190 AC3: a create-time POC equal to the single contact the work
// order will follow is not an override. Store nothing so the work order
// keeps following that contact until a dispatcher edits away from it.
// The followed contact is the linked POC (PocContactId) when one is
// supplied, otherwise the Site primary. Comparing against the followed
// contact rather than any live Site contact means a create-time POC that
// matches a *different* Site contact is stored, since the board only
// shows the one the work order follows. A supplied PocContactId that is
// not among the Site's live contacts leaves no follow target, so the
// typed POC is stored.
var siteContacts = await _pocData.GetSiteContactsAsync(request.LocationId, cancellationToken);
var followContact = request.PocContactId.HasValue
? siteContacts.FirstOrDefault(c => c.Id == request.PocContactId.Value)
: WorkOrderPocSiteContact.ResolvePrimary(siteContacts);
if (WorkOrderPocSiteContact.Matches(pocName, pocPhone, followContact))
{
pocName = null;
pocPhone = null;
}
var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts);
var techPhone = TrimOrNull(request.TechPhone);
var vendorNotes = TrimOrNull(request.VendorNotes);

View file

@ -116,6 +116,7 @@ namespace SeaHaven.Services.Implementation
PocName = row.PocName,
PocPhone = row.PocPhone,
PocNotes = row.PocNotes,
PocCustomized = !string.IsNullOrWhiteSpace(row.WoPocName) || !string.IsNullOrWhiteSpace(row.WoPocPhone),
FrozenSite = MapFrozenSite(row.FrozenSite),
FrozenCompany = MapFrozenCompany(row.FrozenCompany),
FrozenPoc = MapFrozenPoc(row.FrozenPoc),

View file

@ -0,0 +1,176 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
/// <summary>
/// SH-379: persists a manual POC override on an existing work order. The manual
/// value is stored in the WO-level PocName/PocPhone/PocNotes fields, staged as
/// FieldChanged audit entries (which also write field locks so later syncs never
/// overwrite a manual POC, SH-190 AC2), and captured by the completion freeze.
/// An edit equal to the contact the work order currently follows (its linked
/// WorkOrderContacts POC, or else the Site primary) stores no override, so a
/// never-overridden work order keeps following the Site (SH-190 AC3).
/// </summary>
public class WorkOrderPocService : IWorkOrderPocService
{
private readonly IWorkOrderPocDataService _pocData;
private readonly IWorkOrderBoardMutationDataService _mutationData;
private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderPocService(
IWorkOrderPocDataService pocData,
IWorkOrderBoardMutationDataService mutationData,
IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService,
IWorkOrderAccountResolver accountResolver)
{
_pocData = pocData;
_mutationData = mutationData;
_boardService = boardService;
_auditService = auditService;
_accountResolver = accountResolver;
}
public async Task<WorkOrderBoardRowDto> UpdatePocAsync(
int workOrderId,
WorkOrderPocUpdateRequestDto request,
ClaimsPrincipal user,
string? actorId)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
if (workOrderVersion == null)
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
ValidateLengths(request);
await _mutationData.ExecuteTransactionalAsync(async ct =>
{
var workOrder = await _pocData.GetTrackedWorkOrderAsync(workOrderId, accountId, ct);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
if (WorkOrderBoardMutationRules.IsFullyLocked(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
if (WorkOrderBoardMutationRules.IsCoreLocked(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(workOrder.Locations?.Contacts);
var linkedContact = workOrder.WorkOrderContacts?
.OrderBy(item => item.Id)
.FirstOrDefault()?.POC;
var newName = TrimOrNull(request.PocName);
var newPhone = TrimOrNull(request.PocPhone);
var newNotes = TrimOrNull(request.PocNotes);
// SH-190: changing away from the contact the work order follows
// locks the POC. An edit that equals that single contact — the
// linked WorkOrderContacts POC, or else the Site primary — (and
// blanking both fields) stores no override, so the work order keeps
// following it. Comparing against the followed contact rather than
// any live Site contact means editing to a *different* Site contact
// is stored as an override, since the board only ever shows the one
// the work order follows.
var followContact = linkedContact ?? sitePrimary;
var overrideName = newName;
var overridePhone = newPhone;
if ((overrideName != null || overridePhone != null)
&& WorkOrderPocSiteContact.Matches(overrideName, overridePhone, followContact))
{
overrideName = null;
overridePhone = null;
}
var oldEffectiveName = FirstNotBlank(workOrder.PocName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
var oldEffectivePhone = FirstNotBlank(workOrder.PocPhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
var newEffectiveName = FirstNotBlank(overrideName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
var newEffectivePhone = FirstNotBlank(overridePhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
var storedChanged = !string.Equals(workOrder.PocName, overrideName, StringComparison.Ordinal)
|| !string.Equals(workOrder.PocPhone, overridePhone, StringComparison.Ordinal)
|| !string.Equals(workOrder.PocNotes, newNotes, StringComparison.Ordinal);
var oldNotes = workOrder.PocNotes;
workOrder.PocName = overrideName;
workOrder.PocPhone = overridePhone;
workOrder.PocNotes = newNotes;
if (!storedChanged)
return;
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
// Audit effective old -> new values with the same field names create
// uses, so the trail reads consistently across create and update.
if (!string.Equals(oldEffectiveName, newEffectiveName, StringComparison.Ordinal))
await _auditService.StageFieldChangedAsync(workOrderId, "PocName", oldEffectiveName, newEffectiveName, actorId);
if (!string.Equals(oldEffectivePhone, newEffectivePhone, StringComparison.Ordinal))
await _auditService.StageFieldChangedAsync(workOrderId, "PocPhone", oldEffectivePhone, newEffectivePhone, actorId);
if (!string.Equals(TrimOrNull(oldNotes), newNotes, StringComparison.Ordinal))
await _auditService.StageFieldChangedAsync(workOrderId, "PocNotes", TrimOrNull(oldNotes), newNotes, actorId);
var outcome = await _mutationData.SaveAsync(ct);
if (outcome == BoardSaveOutcome.ConcurrencyConflict)
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
}, CancellationToken.None);
var row = await LoadBoardRowAsync(workOrderId, user);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId, ClaimsPrincipal user)
=> await _boardService.GetBoardRowAsync(workOrderId, user);
private static void ValidateLengths(WorkOrderPocUpdateRequestDto request)
{
if (request.PocName?.Length > 200)
throw new WorkOrderBoardValidationException("InvalidValue", "pocName must be at most 200 characters.");
if (request.PocPhone?.Length > 50)
throw new WorkOrderBoardValidationException("InvalidValue", "pocPhone must be at most 50 characters.");
if (request.PocNotes?.Length > 2000)
throw new WorkOrderBoardValidationException("InvalidValue", "pocNotes must be at most 2000 characters.");
}
private static string? ContactDisplayName(Contacts? contact)
=> contact == null
? null
: TrimOrNull(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
private static string? FirstNotBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string? TrimOrNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static byte[]? ParseRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
}
}
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
=> current != null && current.AsSpan().SequenceEqual(expected);
}
}

View file

@ -0,0 +1,14 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Notification Center feed for the signed-in user: live conditions derived from current
/// work-order state at read time, account-scoped from claims and grouped by reason.
/// </summary>
public interface INotificationFeedService
{
Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,18 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// SH-379: persists a manual POC override on an existing work order with audit
/// entries, and applies the SH-190 follow-site / lock semantics.
/// </summary>
public interface IWorkOrderPocService
{
Task<WorkOrderBoardRowDto> UpdatePocAsync(
int workOrderId,
WorkOrderPocUpdateRequestDto request,
ClaimsPrincipal user,
string? actorId);
}
}

View file

@ -80,6 +80,91 @@ public sealed class UpliftQueueReadRelationalTests
Assert.True(item.WorkOrderClosed);
}
[Fact]
public async Task GetPagedAsync_ResolvesDetailModalContext_PerAccountWorkOrder()
{
// SH-209: dispatcher (work-order assignee navigation), technician (vendor
// contact) and scheduled date must translate to SQL through the effective
// work-order subquery, and each row resolves them from its own account's
// work order only.
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new SqliteUpliftTestDbContext(options);
await context.Database.EnsureCreatedAsync();
var accountA = new Accounts { Name = "Account A" };
var accountB = new Accounts { Name = "Account B" };
var dispatcherA = new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" };
var vendorA = new Vendor { CompanyName = "Vendor A", ContactName = "Tech A", IsActive = true };
var vendorB = new Vendor { CompanyName = "Vendor B", ContactName = "Tech B", IsActive = true };
context.AddRange(accountA, accountB, dispatcherA, vendorA, vendorB);
await context.SaveChangesAsync();
var workOrderA = new WorkOrder
{
InternalWONumber = "WO-A",
WorkerOrderTitle = "Repair",
AccountId = accountA.Id,
AssignTo = dispatcherA.Id,
ScheduledDate = new DateTime(2026, 5, 1, 8, 0, 0)
};
var workOrderB = new WorkOrder
{
InternalWONumber = "WO-B",
WorkerOrderTitle = "Repair",
AccountId = accountB.Id
};
context.AddRange(workOrderA, workOrderB);
await context.SaveChangesAsync();
var dispatchA = new Dispatch { VendorId = vendorA.Id, WorkOrderId = workOrderA.Id, DispatchNumber = "DIS-A", Status = "Scheduled" };
var dispatchB = new Dispatch { VendorId = vendorB.Id, WorkOrderId = workOrderB.Id, DispatchNumber = "DIS-B", Status = "Scheduled" };
context.Dispatches.AddRange(dispatchA, dispatchB);
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 4, 1),
RequiredTier = 1,
RequestedNTE = 100m,
NotificationStatus = "Pending"
},
new DispatchUpliftRequest
{
DispatchId = dispatchB.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 4, 2),
RequiredTier = 1,
RequestedNTE = 200m,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
var (total, items) = await data.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None);
Assert.Equal(2, total);
var rowA = Assert.Single(items, i => i.WorkOrderId == workOrderA.Id);
Assert.Equal("Ann", rowA.WorkOrderDispatcherFirstName);
Assert.Equal("Alpha", rowA.WorkOrderDispatcherLastName);
Assert.Equal("Tech A", rowA.TechnicianName);
Assert.Equal(new DateTime(2026, 5, 1, 8, 0, 0), rowA.WorkOrderScheduledDate);
var rowB = Assert.Single(items, i => i.WorkOrderId == workOrderB.Id);
Assert.Null(rowB.WorkOrderDispatcherFirstName);
Assert.Null(rowB.WorkOrderDispatcherLastName);
Assert.Equal("Tech B", rowB.TechnicianName);
Assert.Null(rowB.WorkOrderScheduledDate);
}
private sealed class SqliteUpliftTestDbContext : ApplicationDbContext
{
public SqliteUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)

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);
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
}
[Fact]
@ -232,7 +232,7 @@ public class WorkOrderAccountScopeTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
using var cts = new CancellationTokenSource();
await create.CreateAsync(

View file

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

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

View file

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

View file

@ -2084,11 +2084,40 @@ public class WorkOrderMediaFileRulesTests
}
[Fact]
public void IsAllowed_DeclaredMimeMismatchingExtension_StillRejected()
public void IsAllowed_AllowlistedDeclaredMimeMismatchingExtension_StillRejected()
{
// A concrete declared type is authoritative; only an undetermined one falls back to the extension.
// An allowlisted declared type stays authoritative and pairs against its own
// extension, so declaring video/mp4 for a .mov (or the reverse) is still a spoof.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.mp4", "video/quicktime")));
}
[Theory]
// SH-381: mobile browsers attach a foreign-but-plausible type to a supported container.
// The extension plus magic bytes must decide, not the unreliable declared MIME.
[InlineData("clip.mp4", "video/3gpp")]
[InlineData("VID_0001.MP4", "video/3gpp")]
[InlineData("IMG_1587.MOV", "video/x-quicktime")]
[InlineData("IMG_1587.mov", "video/mpeg")]
[InlineData("photo.jpg", "image/heic")]
public void IsAllowed_ForeignDeclaredMime_ResolvesFromExtensionAndSignature(
string fileName,
string contentType)
{
Assert.True(WorkOrderMediaFileRules.IsAllowed(
FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before));
}
[Fact]
public void IsAllowed_ForeignDeclaredMime_StillRequiresMatchingSignatureAndExtension()
{
// Falling back to the extension does not weaken the gate: the bytes must still match
// the resolved type, the extension must still be supported, and a resolved document
// stays out of photo/video categories.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mp4", "video/3gpp")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.exe", "video/3gpp")));
Assert.False(WorkOrderMediaFileRules.IsAllowed(
FormFile(RealPdf, "report.pdf", "application/x-unknown"), WorkOrderMediaCategory.Before));
}
private static byte[] BytesFor(string fileName)

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

View file

@ -0,0 +1,641 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderPocServiceTests
{
[Fact]
public async Task ManualOverridePersistsIsAuditedAndLocks()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", "Call first", workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Equal("Manual POC", persisted.PocName);
Assert.Equal("312-555-0001", persisted.PocPhone);
Assert.Equal("Call first", persisted.PocNotes);
Assert.Equal("Manual POC", row.PocName);
Assert.Equal("312-555-0001", row.PocPhone);
Assert.True(row.PocCustomized);
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
Assert.Equal("Alice Site", auditName.OldValue);
Assert.Equal("Manual POC", auditName.NewValue);
Assert.Equal("FieldChanged", auditName.Action);
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
}
[Fact]
public async Task ManualOverrideSurvivesCompleteAndLaterSiteChanges()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var updateService = CreateUpdateService(context);
var completed = await updateService.PatchFieldAsync(
workOrder.Id,
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
"actor-1");
Assert.Equal("Manual POC", completed.PocName);
var siteContact = await context.Contacts.SingleAsync(c => c.FirstName == "Alice");
siteContact.FirstName = "Changed";
siteContact.PhoneNumber = "999-555-0100";
await context.SaveChangesAsync();
var row = await CreateBoardService(context).GetBoardRowAsync(
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Manual POC", row!.PocName);
Assert.Equal("312-555-0001", row.PocPhone);
}
[Fact]
public async Task SiteContactUpdateDoesNotOverwriteManualOverride()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
var siteContact = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
siteContact.FirstName = "Rotated";
siteContact.PhoneNumber = "999-555-0100";
await context.SaveChangesAsync();
var row = await CreateBoardService(context).GetBoardRowAsync(
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Manual POC", row!.PocName);
Assert.Equal("312-555-0001", row.PocPhone);
Assert.True(row.PocCustomized);
}
[Fact]
public async Task UnlockedWorkOrderFollowsSiteContact()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
var primary = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
await context.SaveChangesAsync();
var boardService = CreateBoardService(context);
var before = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Alice Site", before!.PocName);
Assert.Equal("312-555-0100", before.PocPhone);
Assert.False(before.PocCustomized);
primary.PhoneNumber = "312-555-0999";
await context.SaveChangesAsync();
var after = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Alice Site", after!.PocName);
Assert.Equal("312-555-0999", after.PocPhone);
Assert.False(after.PocCustomized);
}
[Fact]
public async Task EditEqualToSiteContactDoesNotLock()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc("Alice Site", "312-555-0100", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
Assert.Null(persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.Equal("312-555-0100", row.PocPhone);
Assert.False(row.PocCustomized);
var auditName = await context.WorkOrderAuditLogs.Where(a =>
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName")
.OrderByDescending(a => a.Id)
.FirstAsync();
Assert.Equal("Manual POC", auditName.OldValue);
Assert.Equal("Alice Site", auditName.NewValue);
}
[Fact]
public async Task ClearingBothFieldsFollowsSiteAgain()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var service = CreatePocService(context);
await service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", "keep notes", workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc(null, null, null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
Assert.Null(persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.False(row.PocCustomized);
}
[Fact]
public async Task CrossTenantUpdateIsRejected()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context, accountId: 2);
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(accountId: 1),
"actor-1"));
Assert.Equal("NotFound", ex.Code);
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
}
[Fact]
public async Task MissingAccountScopeIsForbidden()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.MissingScope(),
"actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task CompletedWorkOrderIsReadOnly()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Completed;
workOrder.CompletedDate = DateTime.UtcNow;
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1"));
Assert.Equal("ReadOnly", ex.Code);
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
}
[Fact]
public async Task CanceledWorkOrderIsReadOnly()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
await context.SaveChangesAsync();
var service = CreatePocService(context);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdatePocAsync(
workOrder.Id,
Poc("Manual POC", "312-555-0001", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
[Fact]
public async Task StaleVersionThrowsConflict()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await context.SaveChangesAsync();
var service = CreatePocService(context);
var stale = new WorkOrderPocUpdateRequestDto
{
PocName = "Manual POC",
PocPhone = "312-555-0001",
WorkOrderVersion = Convert.ToBase64String(new byte[] { 9, 9, 9, 9 })
};
await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
service.UpdatePocAsync(workOrder.Id, stale, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Null(persisted.PocName);
}
[Fact]
public async Task CompletionFreezesSiteContactWhenNoOverrideOrLink()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
await context.SaveChangesAsync();
var updateService = CreateUpdateService(context);
var completed = await updateService.PatchFieldAsync(
workOrder.Id,
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
"actor-1");
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
Assert.Equal("Alice Site", completed.PocName);
Assert.Equal("312-555-0100", completed.PocPhone);
var contact = await context.Contacts.SingleAsync();
contact.PhoneNumber = "999-555-0100";
await context.SaveChangesAsync();
var row = await CreateBoardService(context).GetBoardRowAsync(
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
Assert.Equal("Alice Site", row!.PocName);
Assert.Equal("312-555-0100", row.PocPhone);
}
[Fact]
public async Task BoardCreateWithPocEqualToSiteContactFollowsSite()
{
await using var context = CreateContext();
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
context.Contacts.Add(new Contacts
{
FirstName = "Alice",
LastName = "Site",
PhoneNumber = "312-555-0100",
LocationId = 1,
SiteContactOrder = 0
});
await context.SaveChangesAsync();
var service = CreateCreateService(context);
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
PocName = "Alice Site",
PocPhone = "312-555-0100"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Null(persisted.PocName);
Assert.Null(persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.False(row.PocCustomized);
}
[Fact]
public async Task BoardCreateWithPocAwayFromSiteContactIsStored()
{
await using var context = CreateContext();
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
context.Contacts.Add(new Contacts
{
FirstName = "Alice",
LastName = "Site",
PhoneNumber = "312-555-0100",
LocationId = 1,
SiteContactOrder = 0
});
await context.SaveChangesAsync();
var service = CreateCreateService(context);
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
PocName = "Manual POC",
PocPhone = "312-555-0001"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal("Manual POC", persisted.PocName);
Assert.Equal("312-555-0001", persisted.PocPhone);
Assert.True(row.PocCustomized);
}
[Fact]
public async Task EditToSecondSiteContactStoresOverride()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
await context.SaveChangesAsync();
// The WO follows the Site primary (Alice). Editing to the second live Site
// contact (Bob) is a change away from what the board shows and must be
// stored and locked, not silently cleared back to Alice.
var service = CreatePocService(context);
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc("Bob Backup", "312-555-0200", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Equal("Bob Backup", persisted.PocName);
Assert.Equal("312-555-0200", persisted.PocPhone);
Assert.Equal("Bob Backup", row.PocName);
Assert.Equal("312-555-0200", row.PocPhone);
Assert.True(row.PocCustomized);
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
Assert.Equal("Alice Site", auditName.OldValue);
Assert.Equal("Bob Backup", auditName.NewValue);
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
}
[Fact]
public async Task EditToSitePrimaryWhenLinkedContactDiffersStoresOverride()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
var linked = new Contacts
{
FirstName = "Carol",
LastName = "Linked",
PhoneNumber = "312-555-0300"
};
context.Contacts.Add(linked);
await context.SaveChangesAsync();
context.WorkOrderContacts.Add(new WorkOrderContacts
{
WorkorderId = workOrder.Id,
ContactId = linked.Id
});
await context.SaveChangesAsync();
// The WO follows its linked contact (Carol). Typing the Site's primary
// (Alice) is a change away from what the board shows and must be stored,
// not cleared back to Carol.
var service = CreatePocService(context);
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc("Alice Site", "312-555-0100", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Equal("Alice Site", persisted.PocName);
Assert.Equal("312-555-0100", persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.Equal("312-555-0100", row.PocPhone);
Assert.True(row.PocCustomized);
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
Assert.Equal("Carol Linked", auditName.OldValue);
Assert.Equal("Alice Site", auditName.NewValue);
}
[Fact]
public async Task BoardCreateWithPocEqualToSecondSiteContactIsStored()
{
await using var context = CreateContext();
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
context.Contacts.Add(new Contacts
{
FirstName = "Alice",
LastName = "Site",
PhoneNumber = "312-555-0100",
LocationId = 1,
SiteContactOrder = 0
});
context.Contacts.Add(new Contacts
{
FirstName = "Bob",
LastName = "Backup",
PhoneNumber = "312-555-0200",
LocationId = 1,
SiteContactOrder = 1
});
await context.SaveChangesAsync();
// Create-time POC equal to the second Site contact (Bob) is not the contact
// the WO would follow (the primary, Alice), so it must be stored.
var service = CreateCreateService(context);
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
PocName = "Bob Backup",
PocPhone = "312-555-0200"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal("Bob Backup", persisted.PocName);
Assert.Equal("312-555-0200", persisted.PocPhone);
Assert.True(row.PocCustomized);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task SeedScopeAsync(ApplicationDbContext context)
{
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
await context.SaveChangesAsync();
}
private static WorkOrder NewWorkOrder(ApplicationDbContext context, int accountId = 1)
{
var location = context.Locations.Local.Single(l => l.Id == 1);
var workOrder = new WorkOrder
{
Id = 1,
InternalWONumber = "00000012345",
SiteCode = "BK5",
LocationId = location.Id,
Locations = location,
AccountId = accountId,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = Version()
};
context.workOrders.Add(workOrder);
return workOrder;
}
private static async Task<Contacts> SeedSiteContactAsync(
ApplicationDbContext context,
string firstName,
string lastName,
string phone,
int order = 0)
{
var contact = new Contacts
{
FirstName = firstName,
LastName = lastName,
PhoneNumber = phone,
LocationId = 1,
SiteContactOrder = order
};
context.Contacts.Add(contact);
await context.SaveChangesAsync();
return contact;
}
private static WorkOrderPocService CreatePocService(ApplicationDbContext context)
=> new(
new WorkOrderPocDataService(context),
new WorkOrderBoardMutationDataService(context),
CreateBoardService(context),
new WorkOrderAuditService(
new WorkOrderAuditDataService(context),
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
WorkOrderAccountTestHelpers.Resolver(context));
private static WorkOrderBoardService CreateBoardService(ApplicationDbContext context)
=> new(
new WorkOrderBoardDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
}
private static WorkOrderBoardCreateService CreateCreateService(ApplicationDbContext context)
=> new(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
CreateBoardService(context),
new WorkOrderAuditService(
new WorkOrderAuditDataService(context),
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
new WorkOrderBoardCreateValidation(),
WorkOrderAccountTestHelpers.Resolver(context),
new WorkOrderPocDataService(context));
private static WorkOrderPocUpdateRequestDto Poc(
string? name,
string? phone,
string? notes,
WorkOrder workOrder)
=> new()
{
PocName = name,
PocPhone = phone,
PocNotes = notes,
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
};
private static WorkOrderBoardPatchRequestDto Patch(
string field,
string value,
WorkOrder workOrder)
=> new()
{
Field = field,
Value = value,
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
};
private static byte[] Version() => new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
}

52
TODO.md
View file

@ -1,52 +0,0 @@
# SHOC — TODO
## Vendor Portal (Next Priority)
- [ ] Change dispatch email Accept button to "View Work Order" linking to vendor portal
- [ ] Vendor web portal — public pages authenticated by dispatch token
- [ ] Vendor can view their dispatches, accept, update status, complete sign-offs, fill checklists
- [ ] No login required — token-based access from email link
- [ ] Per-WO template selection in multi-WO dispatch
## Dispatch & Vendor Communication
- [x] Dispatch detail modal — view/edit status, dates, NTE, description
- [x] Dispatch number — auto-generated DSP-00001
- [x] DispatchId on Comments — tie vendor comments to specific dispatches
- [x] Vendor activity feed per dispatch
- [x] Internal user → vendor email
- [x] Cancel dispatch button
- [x] Checklist items with template support
- [x] Two-party sign-offs (Customer + Vendor)
- [x] Dispatcher verification gate
- [x] Vendor accept via email link
- [x] Time tracking / wait indicators
- [x] Multi-WO dispatch
- [ ] **Front API integration** — replace SendGrid/SES with Front inbox (vendors@seahaven.com)
- [ ] Add ability for dispatcher to assign a template checklist after initial dispatch
- [ ] Resend dispatch email button
## Work Order List
- [ ] Pinned Actions column — CSS grid doesn't support sticky horizontal scroll, revisit
## Pages Not Yet Built
- [ ] `/profile` — My Profile page (linked from user dropdown)
- [ ] `/settings` — Account Settings page (linked from user dropdown)
- [ ] `/change-password` — Change Password page (linked from user dropdown)
## Controllers Not Yet Built
- [ ] `CalendarController` — backend CRUD for calendar/events (frontend pages exist)
## Dashboard
- [ ] Priority Analysis table — needs backend endpoint to calculate age buckets by severity
- [ ] Invoice Analysis table — placeholder data, no backend yet
## Data & Integration
- [ ] Customer NTE — wire to PO database (placeholder on WO view page)
- [ ] Vendor PO detailed format — line items, billing address, payment terms, expiration, T&C
- [ ] PDF generation for Vendor PO — QuestPDF or similar
- [ ] Address-based distance calculation — upgrade from zip-to-zip to geocoded addresses
- [ ] Backfill location zip codes from address strings during sync
## Deployment
- [ ] When SHOC deploys, switch workorder-ingest Lambda from DynamoDB to direct SHOC API calls
- [ ] Retire DynamoDB sync bridge
- [ ] GitHub branch protection — requires paid plan for private repos

View file

@ -41,6 +41,16 @@ if ! git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then
exit 1
fi
# Diff the change set from the merge base, not from the base tip. A two-dot
# diff against a moving base reports everything the base gained after the
# branch point as if this change reverted it, so a branch behind main that
# touches C# would fail G13 whenever main had merged Terraform in the meantime.
# The merge queue no longer requires branches to be current, so this matters.
DIFF_BASE="$(git merge-base "${BASE_REF}" "${HEAD_REF}")" || {
bad "G3: no merge base between '${BASE_REF}' and '${HEAD_REF}'."
exit 1
}
log "G1: restore"
"$DOTNET" restore "$SOLUTION"
ok "G1: restore"
@ -52,16 +62,16 @@ log "G2: architecture boundary tests (dependency direction)"
--nologo
ok "G2: ArchitectureTests"
log "G3: changed-file formatting (${BASE_REF}..${HEAD_REF})"
log "G3: changed-file formatting (${BASE_REF}...${HEAD_REF}, merge base ${DIFF_BASE:0:7})"
changed_cs=()
while IFS= read -r f; do
changed_cs+=("$f")
done < <(
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}" -- '*.cs'
git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}" -- '*.cs'
)
if (( ${#changed_cs[@]} == 0 )); then
printf '\033[33mSKIP\033[0m G3: no changed C# files between %s..%s\n' "${BASE_REF}" "${HEAD_REF}"
printf '\033[33mSKIP\033[0m G3: no changed C# files between %s...%s\n' "${BASE_REF}" "${HEAD_REF}"
else
printf ' checking %d changed C# file(s)\n' "${#changed_cs[@]}"
"$DOTNET" format "$SOLUTION" \
@ -89,9 +99,9 @@ python3 scripts/test_require_commit_checks.py
python3 scripts/test_check_app_terraform_isolation.py
ok "Release promotion scripts"
log "G13: application and Terraform isolation (${BASE_REF}..${HEAD_REF})"
log "G13: application and Terraform isolation (${BASE_REF}...${HEAD_REF}, merge base ${DIFF_BASE:0:7})"
python3 scripts/check_app_terraform_isolation.py < <(
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}"
git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}"
)
ok "G13: application and Terraform isolation"