Compare commits

...

67 commits

Author SHA1 Message Date
renovate[bot]
59917d718a
chore(deps): update github actions 2026-09-25 19:58:55 +00:00
Alexandre Brandizzi
cf32dd2698
Merge pull request #184 from Sea-Haven-Industries/feat/ab/sh-322-overdue-type
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
feat(work-orders): add Overdue work order type
2026-09-25 19:52:19 +00:00
Alexandre Brandizzi
c6dcad8395
Merge pull request #189 from Sea-Haven-Industries/feat/ab/sh-385-invite-registration
feat(team-members): invite registration with emailed code confirmation (SH-385)
2026-09-25 19:52:15 +00:00
Alexandre Brandizzi
9084b7f642 fix(team-members): answer invalid_invite when send-code finds the invite closed
SendCodeAsync validates the invite, then starts the code with a conditional
update that also requires the invite to still be open. When an admin revoked
the link (or it was used) between those two reads, the refusal was reported as
resend_too_soon with a Retry-After, although the link was already dead.

On a refused start the invite is now read again: a closed invite gets the same
generic invalid_invite response as any other dead link, and a cooldown or send
limit refusal is computed from the fresh row.
2026-09-25 16:40:54 -03:00
Alexandre Brandizzi
fc3a29f399 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
2026-09-25 16:40:36 -03:00
Alexandre Brandizzi
47060f6a73 fix(work-orders): never return a severity on an Overdue board row
A standalone severity patch on an Overdue WO still stores the value,
because the board patches severity before type when correcting Overdue
to Emergency/Reactive and that write must not be dropped or rejected.
Project the severity as null for Overdue in the board row mapping, which
also feeds the PATCH response, search results and the detail view, so a
stored value never surfaces on a type that carries no severity.
2026-09-25 13:41:19 -03:00
Alexandre Brandizzi
bb41bfd8ed Merge remote-tracking branch 'origin/main' into HEAD 2026-09-25 13:40:06 -03:00
Alexandre Brandizzi
77e54e64e3
Merge pull request #194 from Sea-Haven-Industries/feat/ab/sh-389-rejected-uplift-queue
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
feat(uplifts): order the rejected queue by decision time (SH-389)
2026-09-25 16:29:12 +00:00
Alexandre Brandizzi
a8d05776a8
Merge pull request #186 from Sea-Haven-Industries/feat/ab/sh-386-password-policy
feat(auth): enforce one password policy on every password-setting path
2026-09-25 16:27:36 +00:00
Alexandre Brandizzi
5353899418 feat(uplifts): order the rejected queue by decision time and include legacy denied rows 2026-09-25 13:18:06 -03:00
Alexandre Brandizzi
cc328ce22a fix(team-members): do not report an invite email for a deactivated member 2026-09-25 13:13:50 -03:00
Alexandre Brandizzi
afc2330184 fix(team-members): report only password-rule failures at finish as a rejected password 2026-09-25 13:05:35 -03:00
Alexandre Brandizzi
e53415de39 Merge remote-tracking branch 'origin/feat/ab/sh-386-password-policy' into feat/ab/sh-385-invite-registration 2026-09-25 13:04:18 -03:00
Alexandre Brandizzi
9091335ff2 fix(auth): reject an unconfirmed new password and report only policy failures as weak 2026-09-25 13:02:58 -03:00
Alexandre Brandizzi
b50cd5f5df feat(team-members): report whether the re-invite after an email change was emailed 2026-09-25 12:54:27 -03:00
Alexandre Brandizzi
f491d4c721 fix(team-members): delete invites with their member, re-invite on email change, trust only 2xx SendGrid responses 2026-09-25 12:45:04 -03:00
Alexandre Brandizzi
e09ef061d1 Merge remote-tracking branch 'origin/main' into feat/ab/sh-385-invite-registration
# Conflicts:
#	Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
2026-09-25 12:45:03 -03:00
Alexandre Brandizzi
e9a1d8f53f
Merge pull request #190 from Sea-Haven-Industries/fix/ab/wo-created-utc
fix(data): stamp audit times in UTC and keep a work order's creation time
2026-09-25 15:39:33 +00:00
Alexandre Brandizzi
0088cffd47 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	SeaHaven.DataServices/Implementation/LocationDataService.cs
2026-09-25 12:33:37 -03:00
Alexandre Brandizzi
236199ab7a
Merge pull request #187 from Sea-Haven-Industries/feat/ab/sh-331-sites-api
Sites API: unique site codes, safe delete, open work orders, site notes
2026-09-25 15:26:09 +00:00
Alexandre Brandizzi
85b7d1e1c7
Merge pull request #191 from Sea-Haven-Industries/fix/ab/sh-402-effective-permissions
feat(team-members): expose the signed-in user's effective permissions
2026-09-25 15:23:25 +00:00
Alexandre Brandizzi
1c8da7f344 docs(readme): record that stored audit times are UTC and need no backfill
Review asked whether rows written by the old DateTime.Now stamps need a
backfill. They do not: the API has only run on Linux Elastic Beanstalk
hosts at their UTC default, and nothing in Terraform, .ebextensions or
.platform sets a time zone, so DateTime.Now already equalled UTC there.
Record that next to the hosting table so the decision is findable.
2026-09-25 12:20:45 -03:00
Alexandre Brandizzi
385229c64d fix(team-members): keep omitted phone, report invite email failures, never echo invite errors 2026-09-25 12:11:52 -03:00
Alexandre Brandizzi
227691269d style(data): trim trailing whitespace in touched data services 2026-09-25 12:07:46 -03:00
Alexandre Brandizzi
27c21ec32e fix(services): stamp user, contact, calendar and location audit times in UTC
User creation, contact, calendar event and site-contact create/modify/delete stamps used local server time. Validation rules comparing user-entered dates and the JWT expiry are unchanged.
2026-09-25 12:06:36 -03:00
Alexandre Brandizzi
6bfb56f349 fix(data): stamp audit times in UTC and keep a caller-set work order creation time
WorkOrderDataService.AddAsync overwrote the UTC CreatedDate set by WorkOrderService with local server time, offsetting the SLA response clock on any host not running in UTC. Data services now stamp CreatedDate, LastModificationTime and DeletionTime with DateTime.UtcNow, and a work order keeps the creation time its caller set.
2026-09-25 12:04:40 -03:00
Alexandre Brandizzi
13fec977fa feat(team-members): expose the caller's effective permissions
GET api/team-members/me/permissions returns the keys the signed-in user
holds after role defaults and their own overrides, evaluated by the same
policy that guards writes. The user comes from the token; a missing or
unknown identity gets 401.
2026-09-25 12:03:39 -03:00
Alexandre Brandizzi
c8073123e3
Merge pull request #183 from Sea-Haven-Industries/feat/ab/wo-ids-filter
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
feat(workorders): filter board to an exact work-order id set
2026-09-25 14:55:26 +00:00
Alexandre Brandizzi
92dabbfbe3 Hide deleted sites from every location read; require the Add Site fields on create
- Legacy reads (by id, all, by client, paged, address book, exists, count) and the vendor
  preference site check now skip tombstoned sites
- Create requires a client, street address, city, state and at least one complete contact
2026-09-25 11:53:29 -03:00
Alexandre Brandizzi
f9cdbaa06b
Merge pull request #185 from Sea-Haven-Industries/feat/ab/sh-313-completion-templates-api
feat(completion-templates): template content, search, linked work orders and safe delete
2026-09-25 14:49:35 +00:00
Alexandre Brandizzi
c0ae8479ce feat(team-members): invite registration with emailed code confirmation (SH-385) 2026-09-25 11:49:10 -03:00
Alexandre Brandizzi
c1910e5310 test(work-orders): pin severity-then-type correction from Overdue
The board sends one PATCH per field, severity before workOrderType, so
correcting an Overdue work order to Emergency or Reactive patches the
severity while the stored type is still Overdue. Dropping or rejecting a
severity patch for the current type would leave the corrected Emergency
work order with no severity. Overdue's no-severity rule is enforced when
the type changes, not on the severity patch.
2026-09-25 11:41:33 -03:00
Alexandre Brandizzi
a224f883bc fix(completion-templates): let PUT clear workOrderType with an explicit null
UpdateAsync only applied WorkOrderType when it had a value, so once a
template was restricted to one work order type no request could make it
trade-generic again. The DTO now records whether workOrderType was present
in the body: omitting it keeps the stored value, an explicit null clears
it, and a concrete value sets it. The templates page echoes the stored
legacy fields on PUT, so its behaviour is unchanged.
2026-09-25 11:40:49 -03:00
Alexandre Brandizzi
ed5c75223e
Merge pull request #188 from Sea-Haven-Industries/feat/ab/sh-295-sla-alerts
feat(notifications): SEV response-window alerts and breach acknowledgement
2026-09-25 14:31:53 +00:00
Alexandre Brandizzi
d82fb18a3d fix(work-orders): map Overdue to PM catalog in service and template lists 2026-09-25 11:30:34 -03:00
Alexandre Brandizzi
60b1afd8e0 Align the second test project with the site data-service contract
- Recording fake forwards the new site-code and open-work-order queries
- Drop the LocalDB hard-delete test; sites are now tombstoned
2026-09-25 11:25:13 -03:00
Alexandre Brandizzi
cd23ad5b68 fix(completion-templates): read legacy status when counting open linked work orders
Work orders without a LifecycleStatus are open or closed according to
their legacy status text. The linked work-order count now goes through
the shared board status filter, so a legacy completed or cancelled row
is no longer reported as depending on the template.
2026-09-25 11:19:59 -03:00
Alexandre Brandizzi
c3865e56ac Sites API: site code uniqueness, soft delete with role check, open work orders, site notes
- Reject duplicate site codes per client (case-insensitive); site code is immutable once set
- Delete tombstones the site and requires the DeleteSites permission (Admin, Scheduler)
- GET /api/locations/{id}/open-work-orders returns the open count and ids
- PATCH /api/locations/{id}/contact-info saves contacts and notes from the work-order Site dialog
- Add nullable Locations.Notes, used as the site-level POC notes fallback
2026-09-25 11:19:29 -03:00
Alexandre Brandizzi
14c8e46dd0 feat(notifications): SEV response-window alerts and breach acknowledgement
Reactive/Emergency work orders with a SEV 1-5 level are timed from their
creation against the SEV Respond deadline (2/4/8/24/72 hours, one backend
table). From 50% they are at risk: a dismissable High row in the "SLA at
Risk" section and an entry in the feed's slaAtRisk set with the server
clock (start, deadline, percent) for the banner and toast. From 100% they
are a Critical acknowledge row that only acknowledging removes.

POST /api/notifications/sla/{id}/acknowledge records who and when as a
work-order audit entry ("SLA breach acknowledged by <name>"), scoped to the
caller's feed audience: 404 outside it, 409 before the deadline, 204 when
recorded or already recorded. A later severity change is a new breach.
2026-09-25 11:16:21 -03:00
Alexandre Brandizzi
66a49ab957 feat(auth): enforce one password policy on every password-setting path (SH-386)
Both hosts now apply the same Identity password rule: at least 6 characters
with one uppercase letter, one number and one special character. Change
password requires an authenticated caller, verifies the current password
before evaluating the new one, and reports a policy rejection separately
from a wrong current password.
2026-09-25 11:06:42 -03:00
Alexandre Brandizzi
7c097c2750 feat(completion-templates): author templates with safety note and ordered procedures
Adds an extra safety note and an ordered procedure list to completion
document templates, name search, creator and last-updated audit fields,
a tenant-scoped count of open work orders that depend on a template, and
a delete that unlinks Services while they keep requiring a document.
Writes are gated by the create/edit/delete completion template team
permissions instead of the Admin role.
2026-09-25 11:00:22 -03:00
Alexandre Brandizzi
b545d4a4fe feat(work-orders): add Overdue work order type
Overdue (8) is a dispatcher-assigned type, separate from the derived
past-due overlay. It takes no severity, resolves services and
completion-doc templates from the PM catalog, and filters as its own
type. The past-due flag now narrows a type filter instead of widening it,
and the dashboard breakdown partitions by stored type.
2026-09-25 10:57:34 -03:00
Alexandre Brandizzi
3019e71093 feat(workorders): filter board search to an exact work-order id set
GET /board/search accepts ids=1,2,3 (positive ints, deduplicated, at most
200). When present the result is exactly those work orders inside the
caller's tenant and base scope; date, status, dispatcher, facet and text
filters are ignored so none of them can hide a listed work order.
Malformed or oversized lists are a 400.
2026-09-25 10:56:47 -03:00
Alexandre Brandizzi
06eae2fb02
Merge pull request #175 from Sea-Haven-Industries/feat/ab/sh-392-dashboard-unassigned
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
SH-392: count open unassigned work orders on the Dashboard
2026-09-25 06:46:01 +00:00
Alexandre Brandizzi
702069f09c test(dashboard): pin drill-down rows for legacy open work orders
The parity test now also asserts which rows the drill-down lists: legacy
open rows (status in Status or in LegacyStatus, or none) are listed and
legacy closed, cancelled or assigned rows are not. Drops ticket keys
from comments.
2026-09-25 03:38:31 -03:00
Alexandre Brandizzi
d6c5357fab fix(dashboard): count unassigned legacy rows with no lifecycle status (SH-392)
The legacy create paths (WorkOrderDTOs, SyncService, the Blazor
WorkorderService) still write Status without LifecycleStatus. The board
status filter only matched LifecycleStatus. So an open unassigned row of
that kind was left out of the Unassigned tile and its drill-down list,
even though the Open tile in the same response counted it.

ApplyStatusFilter now reads a row with no LifecycleStatus by its legacy
status (LegacyStatus ?? Status), using the Phase0 backfill rules: known
text maps as LifecycleStatusMapper does, and anything else, blank
included, counts as Incomplete. The tile and /board/search share the
predicate, so the count still matches the list it opens.
2026-09-25 03:34:24 -03:00
Alexandre Brandizzi
c49a98db18
Merge branch 'main' into feat/ab/sh-392-dashboard-unassigned 2026-09-25 03:27:33 -03:00
Alexandre Brandizzi
2c8ffaf10e
Merge pull request #173 from Sea-Haven-Industries/fix/ab/sh-383-media-contract
Some checks are pending
Backend CI / Build and test (push) Waiting to run
Backend CI / architecture (push) Waiting to run
Backend CI / review (push) Waiting to run
Backend CI / ci-complete (push) Blocked by required conditions
fix(media): lift the 1 MB proxy body cap and apply the SH-116 media contract
2026-09-25 06:02:21 +00:00
Alexandre Brandizzi
c1ed5dc98d
Merge pull request #181 from Sea-Haven-Industries/fix/ab/sh-400-vendor-readonly-uplifts
Keep work-order uplift requests read-only in the Vendor Portal
2026-09-25 05:58:38 +00:00
Alexandre Brandizzi
207bf59208 fix(media): name HEIC in the unsupported-type message and keep ticket keys out of comments
The rejection message now lists every type the media allowlist accepts, and a
test fails if the message and the allowlist drift apart.
2026-09-25 02:58:15 -03:00
Alexandre Brandizzi
203fc92e4a
Merge pull request #172 from Sea-Haven-Industries/fix/ab/sh-391-adv-search-date-range
fix(board-search): make the Advanced Filters date range narrow results (SH-391)
2026-09-25 05:53:42 +00:00
Alexandre Brandizzi
e02f9774dc Keep work-order uplift requests read-only in the Vendor Portal
A vendor could withdraw (or cancel) an uplift a dispatcher raised from the work
order. Withdraw and its cancel alias now refuse requests with createdby set,
using the portal's not-found response, and the portal read model reports
RaisedByVendor so the portal can hide Revise and Withdraw on those requests.
2026-09-25 02:52:03 -03:00
Alexandre Brandizzi
67cd9c589b fix(board-search): keep undated rows for clients that omit includeDateless (SH-391)
The strict date range made undated open work orders disappear for every
client that predates the includeDateless flag. The frontend on main sends
the all-weeks window (2000-01-01..2099-12-31) for "no range", the
1970-01-01..2099-12-31 window for the pinned Unassigned queue, and no date
input at all for the WO# duplicate lookup. None of those send the flag, so
deploying this backend before the frontend would have dropped undated WOs
from all three flows.

includeDateless is now optional. An explicit value still wins. When it is
omitted, a request with no date input or with an all-weeks Custom window
keeps its open undated rows, as before SH-391. Any other range stays
strict. The backend and frontend can therefore deploy in either order.
2026-09-25 01:41:49 -03:00
Alexandre Brandizzi
0d8f32d148 fix(media): check the file type before the size cap on media upload
AddMedia sized a file before validating its type, and ValidateSize's Unknown
arm returned the video message, so a 150 MB .exe sent as
application/octet-stream was rejected as FileTooLarge with "Videos must be
100 MB or smaller." EnsureAllowed now runs first, so an unsupported file always
reports UnsupportedMediaType, and the Unknown arm uses a type-neutral message.
The oversize controller tests now use real file headers so they pass the type
check before reaching the size cap.
2026-09-24 23:47:18 -03:00
Alexandre Brandizzi
f3ef11b504 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	Api.SeaHavenIndustries/Controllers/VendorPortalController.cs
#	SeaHaven.Services/Implementation/VendorPortalService.cs
2026-09-24 23:05:17 -03:00
Alexandre Brandizzi
3e3f0f383d fix(media): serialize SH-116 media counts under the work-order lock and serve HEIC as image/heic
The 10-photo / 3-video cap was a check-then-insert with no lock on both
upload surfaces, so two overlapping uploads could both take the last slot.
The board media upload and the vendor portal upload now run count, insert
and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic
to image/heic.
2026-09-24 22:56:21 -03:00
Alexandre Brandizzi
eecc2a61bd feat(vendor-portal): report work-order media counts on the dispatch detail
Adds MediaCounts (limits, current photos/videos, and the counts a new
completion version would see) so the portal can refuse an 11th photo or
4th video before uploading it. Uses the same count and replacement rule
the upload check enforces.
2026-09-24 22:24:07 -03:00
Alexandre Brandizzi
7297e30212 fix(dashboard): count All time over the same window the board lists (SH-392)
The Unassigned drill-down opens the board with no range, which searches
2000-01-01..2099-12-31 plus undated work. The All time count had no
bounds, so a work order dated outside that window was counted but not
listed. The count now uses the same window.
2026-09-24 21:45:06 -03:00
Alexandre Brandizzi
fd59a3da13 fix(media): enforce the 90-second video limit on the server
Read the duration from the MP4/MOV movie header (moov/mvhd) on both the
dispatcher media endpoint and the vendor portal completion upload, so a
direct request cannot bypass the browser check. Unreadable metadata still
never blocks an upload.
2026-09-24 21:38:00 -03:00
Alexandre Brandizzi
a8414cd4fa style(tests): format vendor quota test initializers 2026-09-24 21:29:51 -03:00
Alexandre Brandizzi
e15de6e90b fix(media): enforce the per-work-order photo/video limit across both surfaces
The 10-photo / 3-video limit only counted dispatcher attachments, so vendor
portal uploads could push a work order past it (and vice versa). Count the
work order's current vendor documents alongside its attachments on both the
dispatcher media endpoint and the vendor portal. A new completion version
does not count the version it replaces.
2026-09-24 21:27:01 -03:00
Alexandre Brandizzi
16845a55f3 test(vendor-portal): use a valid PDF signature in the size-class regression 2026-09-24 21:18:49 -03:00
Alexandre Brandizzi
07bc81cc52 fix(media): size uploads by the validated content type
A misleading file name could move a file into a larger size class: a real
PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal,
and a .jpg declared with a foreign video type got it on the media endpoint.
Classify by the same resolved type the signature check validates; the
extension only decides when no allowlisted type is known.
2026-09-24 21:18:00 -03:00
Alexandre Brandizzi
4876fa2717 feat(dashboard): count open unassigned work orders for the period (SH-392)
GET /dashboard/stats now returns unassigned: open (not Completed or
Canceled) work orders with no dispatcher in the selected period, within the
caller's server-derived account scope. It uses the same filters as the
Unassigned board search, so the Dashboard number equals the list it drills
into. A dispatcher-scoped Dashboard has no unassigned work and returns 0.
2026-09-24 21:11:31 -03:00
Alexandre Brandizzi
9d5798437d test(workorders): pin the week-only date range rule shared with the board page (SH-391) 2026-09-24 21:11:20 -03:00
Alexandre Brandizzi
dc251c42d4 fix(media): apply SH-116 media contract and lift the 1 MB proxy body cap
The Elastic Beanstalk nginx proxy kept its 1 MB default body limit, so every
media upload over ~1 MB got an nginx 413 before reaching the API. Ship a
.platform nginx override (120M) in the bundle and assert it in the bundle
contract.

Apply the client-confirmed contract: photos up to 10 MB (JPEG/PNG/HEIC),
videos up to 100 MB (MP4/MOV), at most 10 photos and 3 videos per work order,
with stable generic rejection messages. The request ceiling (110 MB) sits
between the per-kind caps and the proxy so oversize files get the generic
message. The vendor portal accepts the same photo/video types and caps.
2026-09-24 20:52:44 -03:00
Alexandre Brandizzi
028908bd5a fix(board-search): make the Advanced Filters date range narrow results (SH-391)
An explicit date range let every undated, non-terminal work order through,
so Unassigned plus a range still returned all ~1,780 unassigned rows. The
range now matches Schedule On, or the Target Week for week-only rows
(overlap), the same date the weekly board groups by. Undated rows are added
only when the caller sends includeDateless, which the client uses for
searches with no range selected and for the Unassigned queue.
2026-09-24 20:51:44 -03:00
174 changed files with 23423 additions and 852 deletions

View file

@ -22,7 +22,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
- name: Set up .NET
uses: actions/setup-dotnet@v6
@ -30,7 +30,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Cache NuGet packages
uses: actions/cache@v4
uses: actions/cache@v4.3.0
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }}
@ -52,7 +52,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
@ -62,7 +62,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Cache NuGet packages
uses: actions/cache@v4
uses: actions/cache@v4.3.0
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }}
@ -88,7 +88,7 @@ jobs:
review:
name: review
if: github.event_name != 'push'
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13
ci-complete:
name: ci-complete

View file

@ -176,7 +176,7 @@ jobs:
- name: Configure AWS credentials using OIDC
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1

View file

@ -0,0 +1,6 @@
# The Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m,
# which returned 413 for every media upload over ~1 MB before the request reached
# the API. The cap sits above the API's own request limit
# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get
# the API's generic per-kind message instead of an nginx error page.
client_max_body_size 120M;

View file

@ -117,11 +117,11 @@ public class AuthenticationControllerTests
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None);
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
@ -130,11 +130,11 @@ public class AuthenticationControllerTests
}
[Fact]
public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus()
public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect });
var controller = NewController(service, "42");
@ -143,6 +143,70 @@ public class AuthenticationControllerTests
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Old Password is incorrect");
response.Message.Should().Be("Current password is incorrect");
}
[Fact]
public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Password does not meet requirements");
response.Message.Should().Be(
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.");
}
[Fact]
public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword()
{
var service = new Mock<IAuthenticationService>();
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Passwords don't match");
service.Verify(
s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" },
CancellationToken.None);
var response = result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Your password could not be changed. Try again.");
response.Message.Should().NotContain("at least 6 characters");
}
[Fact]
public void ChangePassword_RequiresAuthenticatedCaller()
{
var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!;
method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
}
[Fact]

View file

@ -44,7 +44,7 @@ public class CalendarServiceTests
StartDate = startDate,
EndDate = startDate,
IsDeleted = isDeleted,
CreatedDate = DateTime.Now
CreatedDate = DateTime.UtcNow
};
ctx.Events.Add(ev);
ctx.SaveChanges();
@ -64,6 +64,7 @@ public class CalendarServiceTests
saved.Title.Should().Be("Standup");
saved.IsDeleted.Should().Be(false);
saved.CreatedDate.Should().NotBeNull();
saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]
@ -97,6 +98,7 @@ public class CalendarServiceTests
var updated = ctx.Events.Single();
updated.Title.Should().Be("New");
updated.LastModificationTime.Should().NotBeNull();
updated.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]
@ -134,6 +136,7 @@ public class CalendarServiceTests
var row = ctx.Events.Single();
row.IsDeleted.Should().Be(true);
row.DeletionTime.Should().NotBeNull();
row.DeletionTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]

View file

@ -40,7 +40,8 @@ public class DashboardControllerTests
{
ScheduledTomorrow = 3,
PendingUplifts = 5,
AvetaPending = 7
AvetaPending = 7,
Unassigned = 11
});
var result = await NewController(service).GetStats(
@ -50,5 +51,6 @@ public class DashboardControllerTests
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
((int)Prop(body, "pendingUplifts")).Should().Be(5);
((int)Prop(body, "avetaPending")).Should().Be(7);
((int)Prop(body, "unassigned")).Should().Be(11);
}
}

View file

@ -213,10 +213,10 @@ public class DashboardServiceTests
AccountUser(1), query, CancellationToken.None);
stats.Total.Should().Be(4);
stats.Breakdown.Overdue.Should().Be(1);
stats.Breakdown.Overdue.Should().Be(0);
stats.Breakdown.Other.Should().Be(0);
stats.Breakdown.PM.Should().Be(2);
stats.Breakdown.Emergency.Should().Be(0);
stats.Breakdown.Emergency.Should().Be(1);
stats.Breakdown.Reactive.Should().Be(1);
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);

View file

@ -0,0 +1,249 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// The Dashboard "Unassigned" count is open work orders with no
/// dispatcher in the selected period, and it equals the Work Orders list the
/// tile drills into (GET /board/search, Dispatcher = Unassigned).
/// </summary>
public class DashboardUnassignedTests
{
private static readonly DateOnly From = new(2026, 9, 21);
private static readonly DateOnly To = new(2026, 9, 25);
private static readonly List<LifecycleStatus> OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
.ToList();
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx)
=> new(new AccountDataService(ctx), new LocationDataService(ctx));
private static DashboardService NewDashboard(ApplicationDbContext ctx)
=> new(new DashboardDataService(ctx), Resolver(ctx));
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
=> new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx));
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1")
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static WorkOrder Wo(
int id,
DateTime? scheduled,
string? assignTo = null,
LifecycleStatus? status = LifecycleStatus.Scheduled,
DateOnly? targetWeek = null,
int accountId = 1,
string? legacyStatus = null,
string? statusText = null)
=> new()
{
Id = id,
AccountId = accountId,
InternalWONumber = $"2000000{id:0000}",
AssignTo = assignTo,
ScheduledDate = scheduled,
ScheduleWeekOnly = targetWeek.HasValue,
TargetWeek = targetWeek,
LifecycleStatus = status,
LegacyStatus = legacyStatus,
Status = statusText,
istemplate = false
};
// Rows written by legacy create paths, which set Status but no LifecycleStatus.
private static void SeedLegacy(ApplicationDbContext ctx)
{
var inRange = new DateTime(2026, 9, 22);
ctx.workOrders.AddRange(
Wo(13, inRange, status: null, statusText: "Open"), // counted
Wo(14, inRange, status: null, statusText: "On Hold"), // counted: Pending
Wo(15, inRange, status: null), // counted: no status at all
Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins
Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled
Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open"), // assigned
Wo(19, inRange, status: null, legacyStatus: "Open", statusText: "Done")); // counted: LegacyStatus wins
ctx.SaveChanges();
}
private static void Seed(ApplicationDbContext ctx)
{
ctx.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 22)), // counted
Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee
Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range
Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned
Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed),
Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled),
Wo(7, new DateTime(2026, 8, 4)), // out of range
Wo(8, null, status: LifecycleStatus.Incomplete), // undated
Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant
Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated
ctx.SaveChanges();
}
[Fact]
public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod()
{
using var ctx = NewContext();
Seed(ctx);
var stats = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
stats.Unassigned.Should().Be(3);
}
[Fact]
public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated()
{
using var ctx = NewContext();
Seed(ctx);
var stats = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
// 1, 2, 3, 7 (out of any week but open) and 8 (undated).
stats.Unassigned.Should().Be(5);
}
[Fact]
public async Task GetStatsAsync_CountsOpenLegacyRowsWithNoLifecycleStatus()
{
using var ctx = NewContext();
Seed(ctx);
SeedLegacy(ctx);
var inRange = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
var allTime = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
// 1, 2, 3 plus legacy 13, 14, 15 and 19.
inRange.Unassigned.Should().Be(7);
allTime.Unassigned.Should().Be(9);
}
[Fact]
public async Task BoardSearch_StatusFilter_MatchesLegacyRowsByTheirLegacyStatus()
{
using var ctx = NewContext();
var inRange = new DateTime(2026, 9, 22);
ctx.workOrders.AddRange(
Wo(1, inRange),
Wo(2, inRange, status: null, statusText: "scheduled"),
Wo(3, inRange, status: null, statusText: "Open"),
Wo(4, inRange, status: null, statusText: "Legacy Mystery"),
Wo(5, inRange, status: null));
ctx.SaveChanges();
var user = AccountUser(1);
async Task<IEnumerable<int>> Ids(LifecycleStatus status)
=> (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = From,
DateTo = To,
Statuses = new List<LifecycleStatus> { status },
PageSize = 200
}, user, "admin-1")).Items.Select(row => row.Id);
(await Ids(LifecycleStatus.Scheduled)).Should().BeEquivalentTo(new[] { 1, 2 });
// Unknown or missing legacy text reads as Incomplete, as the Phase0 backfill set it.
(await Ids(LifecycleStatus.Incomplete)).Should().BeEquivalentTo(new[] { 3, 4, 5 });
(await Ids(LifecycleStatus.Completed)).Should().BeEmpty();
}
[Fact]
public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders()
{
using var ctx = NewContext();
Seed(ctx);
var accountTwo = await NewDashboard(ctx).GetStatsAsync(
AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None);
var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync(
AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
accountTwo.Unassigned.Should().Be(2);
accountTwoInRange.Unassigned.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork()
{
using var ctx = NewContext();
Seed(ctx);
var dispatcher = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1, role: "Dispatcher", userId: "disp-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync(
AccountUser(1),
new DashboardStatsQueryDTO { DispatcherId = "disp-1" },
CancellationToken.None);
dispatcher.Unassigned.Should().Be(0);
adminPickingDispatcher.Unassigned.Should().Be(0);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange)
{
using var ctx = NewContext();
Seed(ctx);
SeedLegacy(ctx);
// Dated outside the board's all-weeks window: neither side may count them.
ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1)));
ctx.SaveChanges();
var user = AccountUser(1);
var stats = await NewDashboard(ctx).GetStatsAsync(
user,
withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(),
CancellationToken.None);
// What the Work Orders board sends for the tile's drill-down link.
var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = withRange ? From : new DateOnly(2000, 1, 1),
DateTo = withRange ? To : new DateOnly(2099, 12, 31),
IncludeDateless = !withRange,
Dispatchers = new List<string> { "__unassigned__" },
Statuses = OpenStatuses,
PageSize = 200
}, user, "admin-1");
stats.Unassigned.Should().Be(list.TotalCount);
var listed = list.Items.Select(row => row.Id).ToList();
listed.Should().Contain(new[] { 13, 14, 15, 19 });
listed.Should().NotContain(new[] { 16, 17, 18 });
}
}

View file

@ -31,7 +31,9 @@ public class LocationControllerSitesTests
dataService,
new AccountDataService(ctx),
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
Mock.Of<IUpdateLocationValidation>(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
{

View file

@ -286,4 +286,80 @@ public class LocationControllerTests
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
}
[Fact]
public async Task AddLocation_DuplicateSiteCode_Returns409WithStableCode()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
Prop(conflict.Value!, "Code").Should().Be("DuplicateSiteCode");
Prop(conflict.Value!, "Message").Should().Be("This site code already exists.");
}
[Fact]
public async Task EditLocation_DuplicateSiteCode_Returns409()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None);
result.Should().BeOfType<ConflictObjectResult>();
}
[Fact]
public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteForbiddenException(
SeaHaven.Services.Exceptions.SiteForbiddenException.DeleteDeniedMessage));
var result = await NewController(service).DeleteLocation(4, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(403);
forbidden.Value.Should().BeOfType<Response>().Which.Message.Should().Be("You are not allowed to delete sites.");
}
[Fact]
public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None))
.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
(await NewController(service).GetOpenWorkOrders(5, CancellationToken.None))
.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService()
{
var service = new Mock<ILocationService>();
SiteContactInfoRequestDTO? seen = null;
service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny<SiteContactInfoRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.Callback<int, SiteContactInfoRequestDTO, ClaimsPrincipal, CancellationToken>((_, request, _, _) => seen = request)
.Returns(Task.CompletedTask);
var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO
{
Contacts = new List<SiteContactInput_DTO> { new() { Id = 7, Name = "Main", Phone = "555-0100" } },
Notes = "Gate 4"
}, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
seen!.Notes.Should().Be("Gate 4");
seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" });
}
}

View file

@ -15,6 +15,27 @@ namespace Api.SeaHavenIndustries.Tests;
public class LocationServiceTests
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
{
if (request.AccountId == null)
{
if (!ctx.Accounts.Any(a => a.Id == 7))
{
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
ctx.SaveChanges();
}
request.AccountId = 7;
}
request.Address ??= "1 Depot Rd";
request.City ??= "Dallas";
request.State ??= "TX";
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@ -28,7 +49,9 @@ public class LocationServiceTests
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer")
{
@ -83,7 +106,7 @@ public class LocationServiceTests
SeedAccount(ctx, 9);
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Title = "Main WH",
@ -95,7 +118,7 @@ public class LocationServiceTests
ContactEmail = "wh@example.com",
Status = "Active",
AccountId = 9
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
@ -211,7 +234,9 @@ public class LocationServiceTests
dataService,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
[Fact]
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
@ -237,14 +262,14 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Name = "Old",
Address = "9 New St",
City = "Plano",
Status = "Inactive"
}, OrgWideAdmin(), CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("New");
row.Name.Should().Be("Old", "the site code is immutable");
row.Address1.Should().Be("9 New St");
row.City.Should().Be("Plano");
row.Status.Should().Be("Inactive");
@ -263,7 +288,7 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Name = "Old",
City = "Plano"
}, OrgWideAdmin(), CancellationToken.None);
@ -301,12 +326,12 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(
existing.Id,
new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" },
new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" },
AccountUser(4),
CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("Renamed");
row.Name.Should().Be("Owned");
row.City.Should().Be("Austin");
row.AccountId.Should().Be(4);
}
@ -386,7 +411,7 @@ public class LocationServiceTests
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }),
OrgWideAdmin(),
CancellationToken.None);
@ -402,7 +427,7 @@ public class LocationServiceTests
ctx.SaveChanges();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }),
OrgWideAdmin(),
CancellationToken.None);
@ -418,7 +443,7 @@ public class LocationServiceTests
SeedAccount(ctx, 99);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 99 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }),
AccountUser(4),
CancellationToken.None);
@ -453,7 +478,7 @@ public class LocationServiceTests
SeedAccount(ctx, 9);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
MissingScope(),
CancellationToken.None);
@ -476,12 +501,14 @@ public class LocationServiceTests
new LocationDataService(ctx),
accounts.Object,
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
using var cts = new CancellationTokenSource();
await service.CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
OrgWideAdmin(),
cts.Token);
@ -520,20 +547,6 @@ public class LocationServiceTests
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
removed.Should().BeTrue();
again.Should().BeFalse();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
{

View file

@ -22,6 +22,27 @@ namespace Api.SeaHavenIndustries.Tests;
public class LocationSiteContactsTests
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
{
if (request.AccountId == null)
{
if (!ctx.Accounts.Any(a => a.Id == 7))
{
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
ctx.SaveChanges();
}
request.AccountId = 7;
}
request.Address ??= "1 Depot Rd";
request.City ??= "Dallas";
request.State ??= "TX";
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@ -35,7 +56,9 @@ public class LocationSiteContactsTests
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
private static ClaimsPrincipal OrgWideAdmin()
{
@ -51,27 +74,28 @@ public class LocationSiteContactsTests
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
{
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Depot",
Phone = "555-9000",
AccountId = null,
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200"}
}
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
return ctx.Locations.Include(l => l.Contacts).Single();
}
[Fact]
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_SetsAccountFromLocation()
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_KeepsSitePhoneIndependent_SetsAccountFromLocation()
{
using var ctx = NewContext();
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
await ctx.SaveChangesAsync();
await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
AccountId = 7,
@ -80,10 +104,10 @@ public class LocationSiteContactsTests
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200" }
}
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber");
location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts");
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
contacts.Should().HaveCount(2);
@ -104,11 +128,11 @@ public class LocationSiteContactsTests
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>()
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
@ -127,11 +151,11 @@ public class LocationSiteContactsTests
using var ctx = NewContext();
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
if (expectedError == null)
{
@ -148,14 +172,14 @@ public class LocationSiteContactsTests
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = new string('x', 101), Phone = new string('5', 21) }
}
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).Which;
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100"));
@ -170,11 +194,11 @@ public class LocationSiteContactsTests
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
.ToList();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20"));
@ -220,7 +244,7 @@ public class LocationSiteContactsTests
}
[Fact]
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
@ -239,7 +263,7 @@ public class LocationSiteContactsTests
}, OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact");
location.PhoneNumber.Should().BeNull("Site Phone comes from the request, not the first contact");
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
active.Should().HaveCount(3);
@ -304,7 +328,7 @@ public class LocationSiteContactsTests
after.Should().HaveCount(snapshot.Count);
after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location));
ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber
.Should().Be("555-0100", "the rejected update must not persist any change");
.Should().Be("555-9000", "the rejected update must not persist any change");
}
[Fact]
@ -448,7 +472,9 @@ public class LocationSiteContactsTests
data.Object,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
@ -489,67 +515,6 @@ public class LocationSiteContactsTests
rows.Should().BeEmpty();
}
[Fact]
public async Task DataService_DeleteByIdAsync_WithContacts_RemovesLocation_RetainsSoftDeletedDetachedContacts()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var alice = seeded.Contacts!.Single(c => c.FirstName == "Alice Cooper");
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
deleted.Should().BeTrue();
ctx.Locations.Should().BeEmpty();
var retained = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
retained.Should().HaveCount(2);
retained.Should().OnlyContain(c => c.IsDeleted == true);
retained.Should().OnlyContain(c => c.LocationId == null);
retained.Should().OnlyContain(c => c.DeletionTime != null);
retained.Should().OnlyContain(c => c.DeleterUserId == null, "the delete interface carries no actor");
retained.Single(c => c.Id == alice.Id).FirstName.Should().Be("Alice Cooper");
retained.Single(c => c.Id == alice.Id).PhoneNumber.Should().Be("555-0100");
retained.Single(c => c.Id == bob.Id).FirstName.Should().Be("Bob Dillon");
retained.Single(c => c.Id == bob.Id).PhoneNumber.Should().Be("555-0200");
}
[Fact]
public async Task DataService_DeleteByIdAsync_AlreadySoftDeletedContact_IsDetachedWithoutAuditRestamp()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
bob.IsDeleted = true;
bob.DeleterUserId = "admin-1";
bob.DeletionTime = new DateTime(2026, 1, 1);
await ctx.SaveChangesAsync();
var bobStamp = bob.DeletionTime;
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
deleted.Should().BeTrue();
ctx.Locations.Should().BeEmpty();
var retained = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
retained.LocationId.Should().BeNull("previously soft-deleted rows must also detach or the restrict FK blocks the delete");
retained.IsDeleted.Should().BeTrue();
retained.DeleterUserId.Should().Be("admin-1", "original audit stamp is preserved");
retained.DeletionTime.Should().Be(bobStamp);
}
[Fact]
public async Task DataService_DeleteByIdAsync_Missing_ReturnsFalse()
{
using var ctx = NewContext();
await SeedLocationWithContactsAsync(ctx);
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(424242, CancellationToken.None);
deleted.Should().BeFalse();
ctx.Locations.Should().HaveCount(1);
ctx.Contacts.Should().HaveCount(2);
}
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
{
public void UpExposed(MigrationBuilder builder) => Up(builder);
@ -608,7 +573,7 @@ public class LocationSiteContactsTests
using var json = JsonSerializer.SerializeToDocument(ok.Value);
var root = json.RootElement;
root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact");
root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts");
root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name");
var contacts = root.GetProperty("Contacts");
contacts.GetArrayLength().Should().Be(2);
@ -636,7 +601,7 @@ public class LocationSiteContactsTests
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
row.GetProperty("Contact").GetString().Should().Be("Alice Cooper");
row.GetProperty("Phone").GetString().Should().Be("555-0100");
row.GetProperty("Phone").GetString().Should().Be("555-9000");
row.GetProperty("Contacts").GetArrayLength().Should().Be(2);
}
}

View file

@ -7,11 +7,13 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
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 Xunit;
namespace Api.SeaHavenIndustries.Tests;
@ -546,7 +548,7 @@ public class NotificationFeedServiceTests
public async Task Controller_MapsForbiddenScopeTo403()
{
using var context = NewContext();
var controller = new NotificationsController(NewService(context))
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{
@ -565,7 +567,7 @@ public class NotificationFeedServiceTests
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewService(context))
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{

View file

@ -0,0 +1,402 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// SEV response-window alerts in the Notification Center: at risk from 50% of the window (banner set and a
/// dismissable row), breached from 100% (an acknowledge row that only acknowledging removes).
/// </summary>
public class NotificationSlaTests
{
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
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 WorkOrderAccountResolver Resolver(ApplicationDbContext context)
=> new(new AccountDataService(context), new LocationDataService(context));
private static NotificationFeedService NewFeed(ApplicationDbContext context)
=> new(
new NotificationFeedDataService(context),
new VendorOperationsDataService(context, new DispatchDataService(context)),
Resolver(context),
new FixedTimeProvider());
private static SlaBreachAcknowledgementService NewAcknowledgement(ApplicationDbContext context)
=> new(
new NotificationFeedDataService(context),
new WorkOrderAuditDataService(context),
new UserDataService(context),
Resolver(context),
new FixedTimeProvider());
private static ClaimsPrincipal User(int accountId, string role, string userId = "disp-1")
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static WorkOrder Sla(
int id,
TimeSpan age,
string? severity = "1",
WorkOrderType? type = WorkOrderType.Reactive,
int accountId = 1,
string? assignTo = "disp-1",
DateTime? scheduled = null,
LifecycleStatus status = LifecycleStatus.Scheduled)
=> new()
{
Id = id,
AccountId = accountId,
AssignTo = assignTo,
InternalWONumber = $"{1000 + id}",
WorkOrderType = type,
Severity = severity,
CreatedDate = Now - age,
ScheduledDate = scheduled,
LifecycleStatus = status
};
private static TimeSpan Window(int severity) => SlaResponseWindows.Respond[severity];
private static NotificationSectionDto? SlaSection(NotificationFeedDto feed)
=> feed.Sections.SingleOrDefault(section => section.Reason == NotificationReasons.Sla);
private static IEnumerable<string> ItemIds(NotificationFeedDto feed)
=> SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty<string>();
[Fact]
public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge()
{
SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes)
.Should().Equal(new Dictionary<int, double>
{
[1] = 120,
[2] = 240,
[3] = 480,
[4] = 1440,
[5] = 4320
});
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task Thresholds_AtRiskFromHalfTheWindow_BreachedFromTheWholeWindow(int severity)
{
using var context = NewContext();
var second = TimeSpan.FromSeconds(1);
var window = Window(severity);
var level = severity.ToString();
context.workOrders.AddRange(
Sla(1, window / 2 - second, level),
Sla(2, window / 2, level),
Sla(3, window / 2 + second, level),
Sla(4, window - second, level),
Sla(5, window, level),
Sla(6, window + second, level));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
feed.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 2, 3, 4 });
ItemIds(feed).Should().BeEquivalentTo(
"sla-at-risk-2", "sla-at-risk-3", "sla-at-risk-4", "sla-breach-5", "sla-breach-6");
var section = SlaSection(feed)!;
section.Label.Should().Be("SLA at Risk");
section.Count.Should().Be(5);
section.Severity.Should().Be(NotificationSeverities.Critical);
section.Items.Where(item => item.Id.StartsWith("sla-breach-")).Should().OnlyContain(item =>
item.RowType == NotificationRowTypes.Acknowledge
&& item.Severity == NotificationSeverities.Critical
&& item.Title.EndsWith("missed its response deadline"));
section.Items.Where(item => item.Id.StartsWith("sla-at-risk-")).Should().OnlyContain(item =>
item.RowType == NotificationRowTypes.Dismissable
&& item.Severity == NotificationSeverities.High
&& item.Title.EndsWith("is at risk of missing its response deadline"));
}
[Fact]
public async Task AtRisk_CarriesTheServerComputedClock()
{
using var context = NewContext();
context.workOrders.Add(Sla(7, TimeSpan.FromMinutes(90), "1"));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
var atRisk = feed.SlaAtRisk.Should().ContainSingle().Subject;
atRisk.Number.Should().Be("1007");
atRisk.Severity.Should().Be(1);
atRisk.StartedAt.Should().Be(Now.AddMinutes(-90));
atRisk.DeadlineAt.Should().Be(Now.AddMinutes(30));
atRisk.DeadlineAt.Kind.Should().Be(DateTimeKind.Utc);
atRisk.PercentElapsed.Should().Be(75);
SlaSection(feed)!.Items.Single().Title.Should().Be("WO #1007 is at risk of missing its response deadline");
}
[Fact]
public async Task OnlyOpenReactiveOrEmergencyWorkOrdersWithASeverityLevelAreTracked()
{
using var context = NewContext();
var late = Window(1) * 2;
context.workOrders.AddRange(
Sla(1, late, "1", WorkOrderType.Reactive),
Sla(2, late, "2", WorkOrderType.Emergency),
Sla(3, late, "1", WorkOrderType.PM),
Sla(4, late, "1", WorkOrderType.Inspection),
Sla(5, late, "1", type: null),
Sla(6, late, severity: null),
Sla(7, late, severity: "9"),
Sla(8, late, "1", status: LifecycleStatus.Completed),
Sla(9, late, "1", status: LifecycleStatus.Canceled));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
}
[Fact]
public async Task TheClockStartsAtCreation_WhateverTheScheduledDate()
{
using var context = NewContext();
context.workOrders.AddRange(
Sla(1, Window(3) + TimeSpan.FromMinutes(1), "3", scheduled: null),
// Scheduled next week, but logged three days ago: the deadline is long gone.
Sla(2, TimeSpan.FromDays(3), "4", scheduled: Now.AddDays(7)),
// Scheduled days ago, but logged ten minutes ago: nothing is due yet.
Sla(3, TimeSpan.FromMinutes(10), "4", scheduled: Now.AddDays(-5)));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
feed.SlaAtRisk.Should().BeEmpty();
}
[Fact]
public async Task Dispatcher_OnlySeesTheirOwnWorkOrders_AndTheAccountStaysTheBoundary()
{
using var context = NewContext();
var atRisk = Window(2) * 3 / 4;
context.workOrders.AddRange(
Sla(1, atRisk, "2", assignTo: "disp-1"),
Sla(2, atRisk, "2", assignTo: "disp-2"),
Sla(3, atRisk, "2", assignTo: null),
Sla(4, atRisk, "2", assignTo: "disp-1", accountId: 2));
await context.SaveChangesAsync();
var dispatcher = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher", "disp-1"), CancellationToken.None);
var admin = await NewFeed(context).GetFeedAsync(User(1, "Admin", "admin-1"), CancellationToken.None);
var otherAccount = await NewFeed(context).GetFeedAsync(User(2, "Admin", "admin-2"), CancellationToken.None);
dispatcher.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(1);
ItemIds(dispatcher).Should().Equal("sla-at-risk-1");
admin.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 1, 2, 3 });
otherAccount.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(4);
}
[Fact]
public async Task Breaches_AreCappedSeparately_SoAtRiskRowsAlwaysShow()
{
using var context = NewContext();
for (var id = 1; id <= NotificationFeedService.SectionItemLimit + 10; id++)
context.workOrders.Add(Sla(id, Window(1) + TimeSpan.FromMinutes(id), "1"));
context.workOrders.Add(Sla(500, Window(5) * 3 / 4, "5"));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
var section = SlaSection(feed)!;
section.Count.Should().Be(NotificationFeedService.SectionItemLimit + 11);
section.Items.Count(item => item.RowType == NotificationRowTypes.Acknowledge)
.Should().Be(NotificationFeedService.SectionItemLimit);
section.Items.Should().Contain(item => item.Id == "sla-at-risk-500");
// The newest breaches are kept.
section.Items.Should().Contain(item => item.Id == "sla-breach-1");
section.Items.Should().NotContain(item => item.Id == $"sla-breach-{NotificationFeedService.SectionItemLimit + 10}");
}
[Fact]
public async Task Acknowledge_RecordsWhoAndWhenInTheAuditHistory_AndRemovesTheRow()
{
using var context = NewContext();
context.Users.Add(new ApplicationUser { Id = "disp-1", UserName = "jane", FirstName = "Jane", LastName = "Doe" });
context.workOrders.AddRange(
Sla(1, Window(1) + TimeSpan.FromMinutes(5), "1"),
Sla(2, Window(1) + TimeSpan.FromMinutes(9), "1"));
await context.SaveChangesAsync();
var user = User(1, "Dispatcher", "disp-1");
var outcome = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
outcome.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
var audit = await context.WorkOrderAuditLogs.SingleAsync();
audit.WorkOrderId.Should().Be(1);
audit.UserId.Should().Be("disp-1");
audit.CreatedAt.Should().Be(Now);
audit.Action.Should().Be("SLA breach acknowledged by Jane Doe");
audit.FieldName.Should().Be(SlaBreachAcknowledgementAudit.FieldName);
audit.NewValue.Should().Be("1");
audit.EventType.Should().Be("system");
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
ItemIds(feed).Should().Equal("sla-breach-2");
SlaSection(feed)!.Count.Should().Be(1);
var again = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
again.Should().Be(SlaBreachAcknowledgementOutcome.AlreadyAcknowledged);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(1);
}
[Fact]
public async Task Acknowledgement_CoversOnlyTheSeverityThatWasBreached()
{
using var context = NewContext();
var workOrder = Sla(1, TimeSpan.FromHours(30), "2");
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
var user = User(1, "Dispatcher", "disp-1");
await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
workOrder.Severity = "4";
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
ItemIds(feed).Should().Equal("sla-breach-1");
}
[Fact]
public async Task Acknowledge_IsRefusedOutsideTheCallersFeed()
{
using var context = NewContext();
var late = Window(1) * 2;
context.workOrders.AddRange(
Sla(1, late, "1", accountId: 2, assignTo: "disp-1"),
Sla(2, late, "1", assignTo: "disp-2"),
Sla(3, late, "1", status: LifecycleStatus.Completed),
Sla(4, late, "1", WorkOrderType.PM));
await context.SaveChangesAsync();
var service = NewAcknowledgement(context);
var dispatcher = User(1, "Dispatcher", "disp-1");
(await service.AcknowledgeAsync(dispatcher, 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(User(1, "Admin", "admin-1"), 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 2, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 3, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 4, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
var otherAccountAdmin = User(2, "Admin", "admin-2");
(await service.AcknowledgeAsync(otherAccountAdmin, 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
}
[Fact]
public async Task Acknowledge_BeforeTheDeadlineIsRefused()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 3 / 4, "1"));
await context.SaveChangesAsync();
var outcome = await NewAcknowledgement(context)
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, CancellationToken.None);
outcome.Should().Be(SlaBreachAcknowledgementOutcome.NotBreached);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
}
[Fact]
public async Task Acknowledge_WithoutANotificationRole_FailsClosed()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
await context.SaveChangesAsync();
var act = () => NewAcknowledgement(context).AcknowledgeAsync(User(1, "Vendor"), 1, CancellationToken.None);
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Theory]
[InlineData(1, "Dispatcher", 1, StatusCodes.Status204NoContent)]
[InlineData(2, "Admin", 1, StatusCodes.Status404NotFound)]
[InlineData(1, "Dispatcher", 2, StatusCodes.Status409Conflict)]
[InlineData(1, "Vendor", 1, StatusCodes.Status403Forbidden)]
public async Task Controller_MapsAcknowledgeOutcomes(int accountId, string role, int workOrderId, int expectedStatus)
{
using var context = NewContext();
context.workOrders.AddRange(
Sla(1, Window(1) * 2, "1"),
Sla(2, Window(1) * 3 / 4, "1"));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewFeed(context), NewAcknowledgement(context))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(accountId, role, "disp-1") }
}
};
var result = await controller.AcknowledgeSlaBreach(workOrderId, CancellationToken.None);
result.Should().BeAssignableTo<IStatusCodeActionResult>()
.Which.StatusCode.Should().Be(expectedStatus);
}
[Fact]
public async Task Acknowledge_ForwardsCancellation()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
await context.SaveChangesAsync();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => NewAcknowledgement(context)
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
}
}

View file

@ -0,0 +1,218 @@
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Exercises the password rule through the same Identity registration the API
/// host uses, so these assertions describe the rule that runs in production.
/// </summary>
public sealed class PasswordPolicyTests : IAsyncDisposable
{
private const string CurrentPassword = "Current1!";
private readonly ServiceProvider _provider;
private readonly AsyncServiceScope _scope;
public PasswordPolicyTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddSeaHavenIdentity();
_provider = services.BuildServiceProvider();
_scope = _provider.CreateAsyncScope();
}
private UserManager<ApplicationUser> UserManager =>
_scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
[Theory]
[InlineData("Ab1!x", "PasswordTooShort")]
[InlineData("abc12!", "PasswordRequiresUpper")]
[InlineData("Abcde!", "PasswordRequiresDigit")]
[InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")]
public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Select(error => error.Code).Should().Equal(expectedCode);
}
[Theory]
[InlineData("Abc1!x")]
[InlineData("ABC12!")]
public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Should().BeEmpty();
}
[Fact]
public void Registration_AppliesSharedPolicyOptions()
{
var options = _scope.ServiceProvider.GetRequiredService<IOptions<IdentityOptions>>().Value.Password;
options.RequiredLength.Should().Be(6);
options.RequireUppercase.Should().BeTrue();
options.RequireDigit.Should().BeTrue();
options.RequireNonAlphanumeric.Should().BeTrue();
options.RequireLowercase.Should().BeFalse();
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.PasswordRejected);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Succeeded);
var reloaded = await UserManager.FindByIdAsync(user.Id);
(await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue();
}
[Theory]
[InlineData("ConcurrencyFailure")]
[InlineData("PasswordMismatch")]
[InlineData("DefaultError")]
public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code)
{
var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" };
var userManager = new Mock<UserManager<ApplicationUser>>(
Mock.Of<IUserStore<ApplicationUser>>(), null!, null!, null!, null!, null!, null!, null!, null!);
userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user);
userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true);
userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x"))
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" }));
var service = new AuthenticationService(
userManager.Object,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Failed);
}
[Theory]
[InlineData("PasswordTooShort", true)]
[InlineData("PasswordRequiresUpper", true)]
[InlineData("PasswordRequiresDigit", true)]
[InlineData("PasswordRequiresNonAlphanumeric", true)]
[InlineData("ConcurrencyFailure", false)]
[InlineData("PasswordMismatch", false)]
public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected)
{
IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code }))
.Should().Be(expected);
}
[Fact]
public async Task ChangePassword_CancelledToken_Throws()
{
var service = NewAuthenticationService();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
var service = NewAuthenticationService(forget);
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
reset.Should().BeFalse();
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
{
var errors = new List<IdentityError>();
foreach (var validator in UserManager.PasswordValidators)
{
var result = await validator.ValidateAsync(UserManager, user, password);
errors.AddRange(result.Errors);
}
return errors;
}
private async Task<ApplicationUser> CreateUserAsync()
{
var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" };
var created = await UserManager.CreateAsync(user, CurrentPassword);
created.Succeeded.Should().BeTrue();
return user;
}
private AuthenticationService NewAuthenticationService(Mock<IForgetPasswordDataService>? forget = null) =>
new(
UserManager,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>());
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
}
}

View file

@ -0,0 +1,105 @@
using System.Net;
using Api.SeaHavenIndustries.Helper;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using SendGrid;
using SendGrid.Helpers.Mail;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class SendMessageTests
{
private const string Recipient = "taylor@example.com";
private const string Subject = "You're invited to Seahaven";
private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}";
[Theory]
[InlineData(HttpStatusCode.OK, true)]
[InlineData(HttpStatusCode.Accepted, true)]
[InlineData(HttpStatusCode.BadRequest, false)]
[InlineData(HttpStatusCode.Unauthorized, false)]
[InlineData(HttpStatusCode.TooManyRequests, false)]
[InlineData(HttpStatusCode.InternalServerError, false)]
public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered)
{
var sender = new StubbedSendMessage(status, new CapturingLogger());
Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "<p>Hi</p>"));
Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 }));
Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "<p>Hi</p>", null));
}
[Fact]
public async Task ARejectedSend_LogsOnlyTheStatusCode()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>secret link</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains("400", entry);
Assert.DoesNotContain(Recipient, entry);
Assert.DoesNotContain(Subject, entry);
Assert.DoesNotContain("secret link", entry);
Assert.DoesNotContain("errors", entry);
}
[Fact]
public async Task AFailedSend_LogsOnlyTheExceptionType()
{
var logger = new CapturingLogger();
var sender = new StubbedSendMessage(
new HttpRequestException($"could not reach SendGrid for {Recipient}"),
logger);
Assert.False(await sender.SendEMail(Recipient, Subject, "<p>Hi</p>"));
var entry = Assert.Single(logger.Entries);
Assert.Contains(nameof(HttpRequestException), entry);
Assert.DoesNotContain(Recipient, entry);
}
private sealed class StubbedSendMessage : SendMessage
{
private readonly Mock<ISendGridClient> _client = new();
public StubbedSendMessage(HttpStatusCode status, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null));
}
public StubbedSendMessage(Exception failure, ILogger<SendMessage> logger)
: base(new ConfigurationBuilder().Build(), logger)
{
_client
.Setup(client => client.SendEmailAsync(It.IsAny<SendGridMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(failure);
}
protected override ISendGridClient CreateClient(string? apiKey) => _client.Object;
}
private sealed class CapturingLogger : ILogger<SendMessage>
{
public List<string> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add($"{formatter(state, exception)} {exception}");
}
}

View file

@ -0,0 +1,503 @@
using System.Security.Claims;
using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Site registry rules: tenant-scoped unique site codes, immutable codes,
/// permission-gated tombstone delete, open work order lookup and the
/// contact/notes write used by the work-order Site dialog.
/// </summary>
public class SiteRegistryServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx, string? role = "Admin") =>
NewService(new LocationDataService(ctx), new AccountDataService(ctx), role);
private static LocationService NewService(
ILocationDataService data,
IAccountDataService accounts,
string? role = "Admin")
{
var permissions = new Mock<ITeamPermissionOverrideDataService>();
permissions
.Setup(p => p.GetUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string userId, CancellationToken _) => role == null
? null
: new TeamPermissionUserData { UserId = userId, RoleName = role });
return new LocationService(
data,
accounts,
new CreateLocationValidation(),
new UpdateLocationValidation(),
permissions.Object,
new TeamPermissionPolicy());
}
private static ClaimsPrincipal OrgWide(string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin") =>
Principal(role, new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()));
private static ClaimsPrincipal Principal(string role, Claim scope) =>
new(new ClaimsIdentity(new List<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, role),
scope
}, "test"));
private static void SeedAccounts(ApplicationDbContext ctx, params int[] ids)
{
foreach (var id in ids)
ctx.Accounts.Add(new Accounts { Id = id, Name = $"Client {id}", IsDeleted = false });
ctx.SaveChanges();
}
private static Locations SeedSite(ApplicationDbContext ctx, string code, int? accountId, bool deleted = false)
{
var site = new Locations { Name = code, AccountId = accountId, City = "Dallas", State = "TX", IsDeleted = deleted ? true : null };
ctx.Locations.Add(site);
ctx.SaveChanges();
return site;
}
private static WorkOrder Wo(
int id,
int? locationId,
LifecycleStatus? status = LifecycleStatus.Incomplete,
int? accountId = 1,
string? siteCode = null,
string? legacyStatus = null,
bool deleted = false) => new()
{
Id = id,
LocationId = locationId,
LifecycleStatus = status,
LegacyStatus = legacyStatus,
AccountId = accountId,
SiteCode = siteCode,
IsDeleted = deleted ? true : null
};
private static LocationCreateRequestDTO CreateRequest(string code, int? accountId) => new()
{
Name = code,
AccountId = accountId,
Address = "1 Depot Rd",
City = "Dallas",
State = "TX",
Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } }
};
[Fact]
public async Task Create_DuplicateSiteCodeInSameClient_IsRejectedCaseInsensitively()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" bk5 ", 1), OrgWide(), CancellationToken.None);
await act.Should().ThrowAsync<SiteCodeConflictException>();
ctx.Locations.Should().ContainSingle();
}
[Fact]
public async Task Create_SameSiteCodeForAnotherClient_IsAllowed()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1, 2);
SeedSite(ctx, "BK5", 1);
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 2), OrgWide(), CancellationToken.None);
ctx.Locations.Where(l => l.Name == "BK5").Select(l => l.AccountId).Should().BeEquivalentTo(new int?[] { 1, 2 });
}
[Fact]
public async Task Create_SiteCodeOfADeletedSite_CanBeReused()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1, deleted: true);
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), CancellationToken.None);
ctx.Locations.Count(l => l.Name == "BK5" && l.IsDeleted != true).Should().Be(1);
}
[Fact]
public async Task Create_BlankSiteCode_IsAValidationError()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" ", 1), OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code is required.");
}
[Fact]
public async Task Create_StoresTrimmedCodeNotesAndSitePhoneIndependentOfContacts()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var request = CreateRequest(" DFW8 ", 1);
request.Phone = "555-9000";
request.Notes = " Gate code 4411 ";
await NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
var site = ctx.Locations.Single();
site.Name.Should().Be("DFW8");
site.PhoneNumber.Should().Be("555-9000");
site.Notes.Should().Be("Gate code 4411");
}
[Fact]
public async Task Update_ChangingAnExistingSiteCode_IsRejected()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
site.Id, new LocationUpdateRequestDTO { Name = "BK6" }, OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code cannot be changed.");
ctx.Locations.AsNoTracking().Single().Name.Should().Be("BK5");
}
[Fact]
public async Task Update_KeepsCodeAndNotesWhenTheRequestOmitsThem()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
site.Notes = "Dock 3";
site.Status = "Active";
ctx.SaveChanges();
await NewService(ctx).UpdateLocationFromRequestAsync(
site.Id, new LocationUpdateRequestDTO { Name = "bk5", City = "Memphis" }, OrgWide(), CancellationToken.None);
var saved = ctx.Locations.AsNoTracking().Single();
saved.Name.Should().Be("BK5");
saved.City.Should().Be("Memphis");
saved.Notes.Should().Be("Dock 3");
saved.Status.Should().Be("Active");
}
[Fact]
public async Task Update_BlankLegacyCode_CanBeFilledOnceButMustBeUniqueInTheClient()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1);
var legacy = SeedSite(ctx, "", 1);
var duplicate = () => NewService(ctx).UpdateLocationFromRequestAsync(
legacy.Id, new LocationUpdateRequestDTO { Name = "bk5" }, OrgWide(), CancellationToken.None);
await duplicate.Should().ThrowAsync<SiteCodeConflictException>();
await NewService(ctx).UpdateLocationFromRequestAsync(
legacy.Id, new LocationUpdateRequestDTO { Name = "MEM1" }, OrgWide(), CancellationToken.None);
ctx.Locations.AsNoTracking().Single(l => l.Id == legacy.Id).Name.Should().Be("MEM1");
}
[Theory]
[InlineData("Admin")]
[InlineData("Scheduler")]
public async Task Delete_AdminOrScheduler_TombstonesTheSiteAndLeavesWorkOrdersAsTheyWere(string role)
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var site = SeedSite(ctx, "BK5", 1);
ctx.Contacts.Add(new Contacts { LocationId = site.Id, FirstName = "Main", PhoneNumber = "555-0100" });
ctx.workOrders.Add(Wo(10, site.Id));
ctx.SaveChanges();
var deleted = await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None);
deleted.Should().BeTrue();
var tombstone = ctx.Locations.AsNoTracking().Single();
tombstone.IsDeleted.Should().BeTrue();
tombstone.DeleterUserId.Should().Be("actor-1");
ctx.workOrders.AsNoTracking().Single().LocationId.Should().Be(site.Id, "work orders keep their site reference");
ctx.Contacts.AsNoTracking().Single().IsDeleted.Should().NotBe(true, "contacts still back open work orders");
var data = new LocationDataService(ctx);
(await data.GetDetailByIdAsync(site.Id, CancellationToken.None)).Should().BeNull();
(await data.GetSiteOptionsAsync(null, CancellationToken.None)).Should().BeEmpty();
(await data.GetListPagedAsync(1, 10, null, null, CancellationToken.None)).TotalCount.Should().Be(0);
(await data.GetAccountScopeAsync(site.Id, CancellationToken.None)).Exists.Should().BeFalse("a deleted site cannot be assigned to new work orders");
(await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None)).Should().BeFalse();
}
[Theory]
[InlineData("Dispatcher")]
[InlineData(null)]
public async Task Delete_WithoutDeleteSitesPermission_IsForbiddenAndKeepsTheSite(string? role)
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var act = () => NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role ?? "Dispatcher"), CancellationToken.None);
await act.Should().ThrowAsync<SiteForbiddenException>();
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
}
[Fact]
public async Task Delete_SiteOfAnotherClient_IsRejectedForAnAccountScopedCaller()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 2);
var act = () => NewService(ctx).DeleteLocationByIdAsync(site.Id, AccountUser(1), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
}
[Fact]
public async Task OpenWorkOrders_ExcludeTerminalDeletedAndOtherSitesWork()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
var other = SeedSite(ctx, "MEM1", 1);
ctx.workOrders.AddRange(
Wo(1, site.Id),
Wo(2, site.Id, LifecycleStatus.Scheduled),
Wo(3, site.Id, LifecycleStatus.Completed),
Wo(4, site.Id, LifecycleStatus.Canceled),
Wo(5, site.Id, deleted: true),
Wo(6, site.Id, status: null, legacyStatus: "Completed"),
Wo(7, site.Id, status: null, legacyStatus: "Open"),
Wo(8, null, siteCode: "bk5"),
Wo(9, null, siteCode: "BK5", accountId: 2),
Wo(10, other.Id));
ctx.SaveChanges();
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
result!.WorkOrderIds.Should().Equal(1, 2, 7, 8);
result.Count.Should().Be(4);
}
[Fact]
public async Task OpenWorkOrders_AccountScopedCaller_SeesOnlyTheirClientsWork()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
ctx.workOrders.AddRange(Wo(1, site.Id, accountId: 1), Wo(2, site.Id, accountId: 2));
ctx.SaveChanges();
var own = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(1), CancellationToken.None);
own!.WorkOrderIds.Should().Equal(1);
var foreign = () => NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(2), CancellationToken.None);
await foreign.Should().ThrowAsync<UnauthorizedAccessException>();
}
[Fact]
public async Task OpenWorkOrders_ReturnFullCountButCapIds()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
ctx.workOrders.AddRange(Enumerable.Range(1, LocationService.MaxOpenWorkOrderIds + 5).Select(id => Wo(id, site.Id)));
ctx.SaveChanges();
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
result!.Count.Should().Be(LocationService.MaxOpenWorkOrderIds + 5);
result.WorkOrderIds.Should().HaveCount(LocationService.MaxOpenWorkOrderIds);
}
[Fact]
public async Task OpenWorkOrders_MissingOrDeletedSite_ReturnsNull()
{
using var ctx = NewContext();
var deleted = SeedSite(ctx, "BK5", 1, deleted: true);
(await NewService(ctx).GetOpenWorkOrdersAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeNull();
(await NewService(ctx).GetOpenWorkOrdersAsync(999, OrgWide(), CancellationToken.None)).Should().BeNull();
}
[Fact]
public async Task UpdateSiteContactInfo_SavesContactsAndNotesToTheSiteRecord()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var main = new Contacts { LocationId = site.Id, FirstName = "Old Main", PhoneNumber = "555-0100", SiteContactOrder = 0 };
ctx.Contacts.Add(main);
ctx.SaveChanges();
await NewService(ctx).UpdateSiteContactInfoAsync(site.Id, new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO>
{
new() { Id = main.Id, Name = "New Main", Phone = "555-0199" },
new() { Name = "Night Shift", Phone = "555-0200" }
},
Notes = " Call ahead "
}, OrgWide("Dispatcher"), CancellationToken.None);
var saved = ctx.Locations.AsNoTracking().Include(l => l.Contacts).Single();
saved.Notes.Should().Be("Call ahead");
saved.Contacts!.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder)
.Select(c => (c.Id == main.Id, c.FirstName, c.PhoneNumber))
.Should().Equal((true, "New Main", "555-0199"), (false, "Night Shift", "555-0200"));
}
[Fact]
public async Task UpdateSiteContactInfo_RejectsOutOfScopeDeletedAndContactlessRequests()
{
using var ctx = NewContext();
var foreign = SeedSite(ctx, "BK5", 2);
var deleted = SeedSite(ctx, "MEM1", 1, deleted: true);
var request = new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
};
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None)))
.Should().ThrowAsync<UnauthorizedAccessException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<KeyNotFoundException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(
foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<FluentValidation.ValidationException>();
}
[Fact]
public async Task NewSiteOperations_ForwardTheCallersCancellationToken()
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var site = new Locations { Id = 5, Name = "BK5", AccountId = 1, Contacts = new List<Contacts>() };
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetByIdForUpdateAsync(5, token)).ReturnsAsync(site);
data.Setup(d => d.GetDetailByIdAsync(5, token)).ReturnsAsync(site);
data.Setup(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token))
.ReturnsAsync((1, new[] { 7 }));
var service = NewService(data.Object, Mock.Of<IAccountDataService>());
await service.GetOpenWorkOrdersAsync(5, OrgWide(), token);
await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
}, OrgWide(), token);
await service.DeleteLocationByIdAsync(5, OrgWide(), token);
data.Verify(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token), Times.Once);
data.Verify(d => d.UpdateAsync(site, token), Times.Exactly(2));
}
[Fact]
public async Task Create_DuplicateCheck_ForwardsTheCallersCancellationToken()
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var data = new Mock<ILocationDataService>();
data.Setup(d => d.SiteCodeExistsAsync("BK5", 1, null, token)).ReturnsAsync(true);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsActiveAsync(1, token)).ReturnsAsync(true);
var service = NewService(data.Object, accounts.Object);
var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token);
await act.Should().ThrowAsync<SiteCodeConflictException>();
data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once);
}
[Fact]
public void CompletionSnapshot_FreezesSiteNotesWhenTheWorkOrderHasNone()
{
var workOrder = new WorkOrder
{
Id = 1,
Locations = new Locations { Id = 5, Name = "BK5", Notes = "Gate code 4411", Contacts = new List<Contacts>() }
};
WorkOrderCompletionSnapshotMapper.Capture(workOrder);
using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!);
frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411");
}
public static TheoryData<string, Action<LocationCreateRequestDTO>, string> MissingSiteFields => new()
{
{ "no client", r => r.AccountId = null, "Client is required." },
{ "no street address", r => r.Address = " ", "Street Address is required." },
{ "no city", r => r.City = null, "City is required." },
{ "no state", r => r.State = "", "State is required." },
{ "no contacts list", r => r.Contacts = null, "At least one contact is required." },
{ "empty contacts list", r => r.Contacts = new List<SiteContactRequestDTO>(), "At least one contact is required." },
{ "contact without phone", r => r.Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = " " } }, "Contact phone is required." }
};
[Theory]
[MemberData(nameof(MissingSiteFields))]
public async Task Create_WithoutARequiredSiteField_IsAValidationErrorAndStoresNothing(
string _,
Action<LocationCreateRequestDTO> strip,
string expectedMessage)
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var request = CreateRequest("BK9", 1);
strip(request);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().Contain(e => e.ErrorMessage == expectedMessage);
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task Delete_RemovesTheSiteFromEveryLegacyRead()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var deleted = SeedSite(ctx, "BK5", 1);
var kept = SeedSite(ctx, "BK6", 1);
var service = NewService(ctx);
(await service.DeleteLocationByIdAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeTrue();
(await service.GetLocationByIdAsync(deleted.Id)).Should().BeNull();
(await service.GetLocationByIdWithDetailsAsync(deleted.Id)).Should().BeNull();
(await service.LocationExistsAsync(deleted.Id)).Should().BeFalse();
(await service.GetTotalLocationCountAsync()).Should().Be(1);
(await service.GetAllLocationsAsync()).Select(l => l.Id).Should().Equal(kept.Id);
(await service.GetLocationsByAccountIdAsync(1)).Select(l => l.Id).Should().Equal(kept.Id);
(await service.GetLocationsPagedAsync(1, 10)).Items.Select(l => l.Id).Should().Equal(kept.Id);
(await new LocationDataService(ctx).GetAddressbookPagedAsync(1, 10)).TotalCount.Should().Be(1);
(await new VendorOperationsDataService(ctx, Mock.Of<IDispatchDataService>()).LocationExistsAsync(deleted.Id, CancellationToken.None)).Should().BeFalse();
}
}

View file

@ -0,0 +1,171 @@
using System.Reflection;
using System.Text.Json;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class TeamMemberInviteControllerTests
{
[Fact]
public void RegistrationRequests_CarryNoUserIdentifier()
{
var requestTypes = new[]
{
typeof(TeamMemberInviteTokenRequestDTO),
typeof(VerifyTeamMemberInviteCodeRequestDTO),
typeof(CompleteTeamMemberRegistrationRequestDTO)
};
var properties = requestTypes
.SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.Select(property => property.Name)
.Distinct()
.OrderBy(name => name);
properties.Should().Equal("Code", "Password", "Phone", "Token");
}
[Fact]
public void RegistrationEndpoints_AreAnonymousAndNeverCached()
{
var type = typeof(TeamMemberInviteController);
type.GetCustomAttribute<AllowAnonymousAttribute>().Should().NotBeNull();
var cache = type.GetCustomAttribute<ResponseCacheAttribute>();
cache.Should().NotBeNull();
cache!.NoStore.Should().BeTrue();
}
[Fact]
public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter()
{
var filter = typeof(TeamMemberInviteController).GetCustomAttribute<TypeFilterAttribute>();
filter.Should().NotBeNull();
filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter));
}
[Fact]
public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail()
{
var context = ExceptionContextFor(new ArgumentException(
"SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'"));
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeTrue();
var result = context.Result.Should().BeOfType<ObjectResult>().Subject;
result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var body = JsonSerializer.Serialize(result.Value);
body.Should().Be(
"{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}");
body.Should().NotContain("SELECT").And.NotContain("leak-me");
}
[Fact]
public void ExceptionFilter_LeavesCancellationToTheHost()
{
var context = ExceptionContextFor(new OperationCanceledException());
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeFalse();
context.Result.Should().BeNull();
}
private static ExceptionContext ExceptionContextFor(Exception exception)
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
return new ExceptionContext(actionContext, new List<IFilterMetadata>()) { Exception = exception };
}
[Theory]
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
[InlineData(TeamMemberRegistrationStatus.Ok)]
public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status)
{
var service = new Mock<ITeamMemberRegistrationService>();
service.Setup(x => x.CompleteAsync(It.IsAny<CompleteTeamMemberRegistrationRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status });
var controller = new TeamMemberInviteController(service.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None);
var failure = result.Should().BeOfType<ObjectResult>().Subject;
failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
failure.Value.Should().BeEquivalentTo(new
{
code = "invalid_invite",
message = TeamMemberInviteController.InvalidInviteMessage
});
}
[Fact]
public void ResendInvite_RequiresAuthenticatedCaller()
{
typeof(TeamMemberController).GetCustomAttribute<AuthorizeAttribute>().Should().NotBeNull();
typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))!
.GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
}
[Fact]
public async Task ResendInvite_Success_ReturnsInviteSent()
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" });
}
[Theory]
[InlineData("Forbidden", typeof(ForbidResult))]
[InlineData("Team member not found.", typeof(NotFoundObjectResult))]
[InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))]
[InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))]
public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected)
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType(expected);
}
private static TeamMemberController NewTeamMemberController(Mock<ITeamMemberInviteService> invites)
{
return new TeamMemberController(Mock.Of<ITeamMemberService>(), Mock.Of<ITeamPermissionService>(), invites.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"))
}
}
};
}
}

View file

@ -0,0 +1,98 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class TeamMemberPermissionsEndpointTests
{
[Fact]
public async Task Signed_in_caller_receives_their_effective_keys_as_a_permissions_array()
{
var data = new Mock<ITeamPermissionOverrideDataService>();
data.Setup(d => d.GetUserAsync("u1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamPermissionUserData
{
UserId = "u1",
RoleName = "Dispatcher",
Overrides = new Dictionary<string, UserPermissionState>
{
[TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow
}
});
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer")));
var ok = Assert.IsType<OkObjectResult>(await controller.GetMyPermissions(CancellationToken.None));
var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var document = JsonDocument.Parse(json);
var keys = document.RootElement.GetProperty("permissions").EnumerateArray()
.Select(element => element.GetString()).ToList();
Assert.Contains(TeamPermissionKeys.CreateCompletionDocTemplates, keys);
Assert.DoesNotContain(TeamPermissionKeys.DeleteCompletionDocTemplates, keys);
}
[Fact]
public async Task Unauthenticated_caller_gets_401_without_data_access()
{
var data = new Mock<ITeamPermissionOverrideDataService>(MockBehavior.Strict);
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity()));
var result = Assert.IsType<UnauthorizedResult>(await controller.GetMyPermissions(CancellationToken.None));
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
}
[Fact]
public void Route_is_get_me_permissions_and_requires_authentication()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore().AddApplicationPart(typeof(TeamMemberController).Assembly);
using var provider = services.BuildServiceProvider();
var descriptor = provider
.GetRequiredService<IActionDescriptorCollectionProvider>()
.ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Single(d => d.ControllerTypeInfo == typeof(TeamMemberController)
&& d.ActionName == nameof(TeamMemberController.GetMyPermissions));
var methods = descriptor.ActionConstraints!.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods);
Assert.Equal(new[] { "GET" }, methods);
Assert.Equal("api/team-members/me/permissions", descriptor.AttributeRouteInfo!.Template);
Assert.NotEmpty(typeof(TeamMemberController).GetCustomAttributes(typeof(AuthorizeAttribute), true));
Assert.Empty(descriptor.MethodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true));
}
private static TeamMemberController Controller(
ITeamPermissionOverrideDataService data,
ClaimsPrincipal user) =>
new(
Mock.Of<ITeamMemberService>(),
new TeamPermissionService(data, new TeamPermissionPolicy()),
Mock.Of<ITeamMemberInviteService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
}
};
}

View file

@ -28,7 +28,8 @@ public sealed class TeamMemberServiceTests
Mock.Of<IUserServiceAreaDataService>(),
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>());
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
@ -408,7 +409,8 @@ public sealed class TeamMemberServiceTests
areas.Object,
overrides.Object,
userData.Object,
permissions.Object);
permissions.Object,
Mock.Of<ITeamMemberInviteService>());
}
private static Mock<UserManager<ApplicationUser>> UserManager()

View file

@ -164,6 +164,59 @@ public sealed class UpliftQueueReadTests
new DateTime(2026, 3, 10));
}
[Fact]
public async Task List_RejectedStatus_OrdersMostRecentlyRejectedFirst()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
// Request order (3/1, 3/2, 3/3) deliberately disagrees with decision order.
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)),
Request(dispatch, "Rejected", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)),
Request(dispatch, "Rejected", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.DecidedAt).Should().Equal(
new DateTime(2026, 3, 20),
new DateTime(2026, 3, 15),
new DateTime(2026, 3, 10));
}
[Fact]
public async Task List_RejectedStatus_ReturnsOnlyRejectedRequests_IncludingLegacyDenied()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 3)),
Request(dispatch, "Revoked", new DateTime(2026, 3, 4), decided: new DateTime(2026, 3, 5)),
Request(dispatch, "Withdrawn", new DateTime(2026, 3, 6)),
Request(dispatch, "Rejected", new DateTime(2026, 3, 7), decided: new DateTime(2026, 3, 8)),
Request(dispatch, "Denied", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None);
result.Total.Should().Be(2);
result.Items.Select(i => i.Status).Should().Equal("Rejected", "Rejected");
result.Items.Select(i => i.DecidedAt).Should().Equal(
new DateTime(2026, 3, 8),
new DateTime(2026, 2, 2));
}
[Fact]
public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder()
{
@ -558,6 +611,40 @@ public sealed class UpliftQueueReadTests
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
}
[Fact]
public async Task List_RejectedRow_CarriesRejecterRequesterDecisionTimeAndReason()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-R", "SITE-R", "Plumbing");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-R");
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Lovelace" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Rejected",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2, 16, 40, 0),
DecidedByUserId = "user-7",
DecisionNote = "Outside this work order's scope",
RequestedByVendorName = "Gateway",
RequiredTier = 1,
RequestedNTE = 250m,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None);
var row = result.Items.Single();
row.DecidedByName.Should().Be("Ada Lovelace");
row.RequestedByName.Should().Be("Gateway");
row.DecidedAt.Should().Be(new DateTime(2026, 3, 2, 16, 40, 0));
row.DecisionNote.Should().Be("Outside this work order's scope");
row.WorkOrderNumber.Should().Be("WO-R");
}
[Fact]
public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue()
{

View file

@ -496,6 +496,92 @@ public sealed class UpliftWorkflowTests
await act.Should().ThrowAsync<InvalidOperationException>();
}
// Raised from the work order by an internal user: createdby is set and RequestedNTE
// holds the increase. Vendor sessions have no identity user, so they never set it.
private static DispatchUpliftRequest WorkOrderRequest(int dispatchId, string status) => new()
{
DispatchId = dispatchId,
CurrentNTE = 600m,
RequestedNTE = 90m,
Status = status,
RequiredTier = 1,
VendorReason = "Extra parts",
RequestedByVendorName = "Alex Dispatcher",
NotificationStatus = "Sent",
createdby = "dispatcher-1",
CreatedDate = new DateTime(2026, 3, 1),
};
[Theory]
[InlineData("Pending", "withdraw")]
[InlineData("ChangesRequested", "withdraw")]
[InlineData("Pending", "cancel")]
[InlineData("ChangesRequested", "cancel")]
public async Task Withdraw_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged(string status, string route)
{
using var context = NewContext();
var (_, _, dispatch) = await SeedAsync(context, nte: 600m);
var workOrderRequest = WorkOrderRequest(dispatch.Id, status);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
Func<Task> act = route == "cancel"
? () => service.CancelUpliftRequestAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None)
: () => service.WithdrawUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id);
after.Status.Should().Be(status);
after.DecidedAt.Should().BeNull();
after.RequestedNTE.Should().Be(90m);
after.createdby.Should().Be("dispatcher-1");
context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_withdraw" || a.Action == "uplift_cancel");
}
[Fact]
public async Task Cancel_VendorRaisedChangesRequested_StillWithdraws()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
context.DispatchUpliftRequests.Single().Status = UpliftStatus.ChangesRequested;
await context.SaveChangesAsync();
var result = await service.CancelUpliftRequestAsync(session!, dispatch.Id, created.Id, CancellationToken.None);
result.Status.Should().Be(UpliftStatus.Withdrawn);
context.DispatchUpliftRequests.AsNoTracking().Single().Status.Should().Be(UpliftStatus.Withdrawn);
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_cancel" && a.OldValue == UpliftStatus.ChangesRequested);
}
[Fact]
public async Task DispatchDetail_ReportsRaisedByVendor_PerCreationPath()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
await context.SaveChangesAsync();
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var vendorRaised = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
await service.WithdrawUpliftAsync(session!, dispatch.Id, vendorRaised.Id, CancellationToken.None);
var workOrderRequest = WorkOrderRequest(dispatch.Id, UpliftStatus.Pending);
context.DispatchUpliftRequests.Add(workOrderRequest);
await context.SaveChangesAsync();
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.UpliftRequests.Should().HaveCount(2);
detail.UpliftRequests.Single(u => u.Id == vendorRaised.Id).RaisedByVendor.Should().BeTrue();
detail.UpliftRequests.Single(u => u.Id == workOrderRequest.Id).RaisedByVendor.Should().BeFalse();
}
[Fact]
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
{

View file

@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
It.IsAny<int>(),
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
It.IsAny<CancellationToken>()))
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());
@ -363,6 +368,278 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public void UploadCompletionDocument_AdvertisesContractRequestLimit()
{
var attribute = Assert.Single(
typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.UploadCompletionDocument))!
.CustomAttributes,
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
bytes.Value.Should().Be(110_000_000L);
}
private static byte[] MediaBytes(int size, params byte[] header)
{
var bytes = new byte[size];
header.AsSpan().CopyTo(bytes);
return bytes;
}
private static byte[] FtypVideoBytes(int size) => MediaBytes(
size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p',
(byte)'i', (byte)'s', (byte)'o', (byte)'m');
[Fact]
public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4"));
result.Should().BeOfType<OkObjectResult>();
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ContentType.Should().Be("video/mp4");
document.SizeBytes.Should().Be(60_000_000L);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", ""));
result.Should().BeOfType<OkObjectResult>();
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime");
}
[Fact]
public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic"));
result.Should().BeOfType<OkObjectResult>();
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg");
}
[Theory]
// Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must
// follow the validated type, not the name, or the document/photo caps are bypassed.
[InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })]
[InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })]
public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap(
string fileName,
string contentType,
int size,
byte[] header)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(size, header), fileName, contentType));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
private static byte[] PhoneVideoBytes(uint durationSeconds)
{
static byte[] Box(string type, byte[] body)
{
var box = new byte[8 + body.Length];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
body.CopyTo(box, 8);
return box;
}
var mvhd = new byte[20];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600);
return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
.Concat(Box("mdat", new byte[4096]))
.Concat(Box("moov", Box("mvhd", mvhd)))
.ToArray();
}
[Theory]
[InlineData(95u, false)]
[InlineData(89u, true)]
public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime"));
if (accepted)
{
result.Should().BeOfType<OkObjectResult>();
}
else
{
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
}
[Fact]
public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
ContentType = "video/mp4",
StoredFileName = "v1.mp4",
Version = 1,
Purpose = "Completion"
});
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(2);
}
[Fact]
public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
ContentType = "video/mp4",
StoredFileName = "v1.mp4",
Version = 1,
Purpose = "Completion"
});
await context.SaveChangesAsync();
var service = NewService(context);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO
{
MaxPhotos = 10,
MaxVideos = 3,
Photos = 1,
Videos = 3,
CompletionPhotos = 1,
CompletionVideos = 2,
});
}
[Fact]
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
{

View file

@ -0,0 +1,68 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// The SLA clock and every "created"/"modified" display read these stamps as UTC, so the data layer
/// must never write local server time into them.
/// </summary>
public class WorkOrderDataServiceTimestampTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrder NewWorkOrder() =>
new() { InternalWONumber = "WO-1", WorkerOrderTitle = "Leak", LocationId = 100 };
[Fact]
public async Task AddAsync_KeepsTheCreationTimeTheCallerSet()
{
await using var context = NewContext();
var createdAt = new DateTime(2026, 9, 25, 14, 0, 0, DateTimeKind.Utc);
var workOrder = NewWorkOrder();
workOrder.CreatedDate = createdAt;
var saved = await new WorkOrderDataService(context).AddAsync(workOrder);
saved.CreatedDate.Should().Be(createdAt);
saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc);
(await context.workOrders.SingleAsync()).CreatedDate.Should().Be(createdAt);
}
[Fact]
public async Task AddAsync_StampsUtcWhenTheCallerSetNoCreationTime()
{
await using var context = NewContext();
var before = DateTime.UtcNow;
var saved = await new WorkOrderDataService(context).AddAsync(NewWorkOrder());
saved.CreatedDate.Should().NotBeNull();
saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc);
saved.CreatedDate.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow);
}
[Fact]
public async Task UpdateAsync_StampsTheModificationTimeInUtc()
{
await using var context = NewContext();
var service = new WorkOrderDataService(context);
var saved = await service.AddAsync(NewWorkOrder());
var before = DateTime.UtcNow;
await service.UpdateAsync(saved);
saved.LastModificationTime.Should().NotBeNull();
saved.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
saved.LastModificationTime.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow);
}
}

View file

@ -0,0 +1,217 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope,
/// whatever other filters the board sends alongside.
/// </summary>
public class WorkOrderIdsFilterTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
=> new(
new WorkOrderAdvancedSearchDataService(ctx),
new WorkOrderAccountResolver(new AccountDataService(ctx), new LocationDataService(ctx)));
private static ClaimsPrincipal AccountUser(int accountId)
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
private static WorkOrder Wo(
int id,
DateTime? scheduled,
int accountId = 1,
string? assignTo = "disp-1",
LifecycleStatus? status = LifecycleStatus.Scheduled,
bool? deleted = null,
bool template = false)
=> new()
{
Id = id,
AccountId = accountId,
InternalWONumber = $"3000000{id:0000}",
AssignTo = assignTo,
ScheduledDate = scheduled,
LifecycleStatus = status,
IsDeleted = deleted,
istemplate = template
};
private static void Seed(ApplicationDbContext ctx)
{
ctx.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 22)),
Wo(2, null), // no schedule date
Wo(3, new DateTime(2025, 1, 6), status: LifecycleStatus.Completed),
Wo(4, new DateTime(2026, 9, 22), assignTo: "disp-2"),
Wo(5, new DateTime(2026, 9, 22)), // not requested
Wo(6, new DateTime(2026, 9, 22), accountId: 2), // another tenant
Wo(7, new DateTime(2026, 9, 22), deleted: true),
Wo(8, new DateTime(2026, 9, 22), template: true));
ctx.SaveChanges();
}
[Fact]
public async Task Ids_ReturnExactlyThoseWorkOrders_IgnoringEveryOtherFilter()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "4,1,2,3",
// Filters the board may still carry; none of them may hide a listed work order.
DatePreset = WorkOrderAdvancedSearchDatePreset.ThisWeek,
Statuses = new List<LifecycleStatus> { LifecycleStatus.Scheduled },
Dispatchers = new List<string> { "disp-1" },
Search = "no match anywhere",
PageSize = 200
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(4);
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 });
}
[Fact]
public async Task Ids_NeverWidenTenantOrBaseScope()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "1,6,7,8",
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().Equal(1);
result.TotalCount.Should().Be(1);
}
[Fact]
public async Task Ids_OnlyAnotherTenantsWorkOrders_ReturnsNothing()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "6"
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(0);
result.Items.Should().BeEmpty();
}
[Fact]
public async Task NoIds_KeepsTheExistingFilters()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2026, 9, 21),
DateTo = new DateOnly(2026, 9, 25),
Dispatchers = new List<string> { "disp-1" },
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 5 });
}
[Theory]
[InlineData("1,abc")]
[InlineData("0")]
[InlineData("-3")]
[InlineData("1,,2")]
[InlineData("1.5")]
[InlineData("99999999999")]
public async Task MalformedIds_AreABadRequest(string ids)
{
var controller = NewController();
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
badRequest.Value.Should().BeOfType<Response>().Which.Message.Should().Contain("ids");
}
[Fact]
public async Task MoreThanTheLimit_IsABadRequest()
{
var controller = NewController();
var ids = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount + 1));
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
result.Should().BeOfType<BadRequestObjectResult>()
.Which.Value.Should().BeOfType<Response>()
.Which.Message.Should().Contain("200");
}
[Fact]
public void Parse_DeduplicatesBeforeCountingAndKeepsFirstSeenOrder()
{
var withDuplicates = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount).Concat(new[] { 5, 7 }));
WorkOrderIdSet.ParseOrThrow(withDuplicates).Should().HaveCount(WorkOrderIdSet.MaxCount);
WorkOrderIdSet.ParseOrThrow(" 9, 3 ,9 ").Should().Equal(9, 3);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_AbsentOrBlank_IsNoFilter(string? ids)
{
WorkOrderIdSet.ParseOrThrow(ids).Should().BeNull();
}
private static WorkOrderBoardController NewController()
{
var search = new WorkOrderAdvancedSearchService(
Mock.Of<SeaHaven.DataServices.Interfaces.IWorkOrderAdvancedSearchDataService>(),
Mock.Of<IWorkOrderAccountResolver>());
return new WorkOrderBoardController(
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
search)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = AccountUser(1) }
}
};
}
}

View file

@ -1,5 +1,6 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests
}
[Fact]
public void AddMedia_HasTwoHundredMegabyteRequestLimit()
public void AddMedia_HasContractRequestLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.Single(
@ -39,36 +40,293 @@ public class WorkOrderMediaControllerTests
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
var bytes = Assert.Single(attribute.ConstructorArguments);
Assert.Equal(200_000_000L, bytes.Value);
Assert.Equal(110_000_000L, bytes.Value);
}
[Fact]
public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit()
public void AddMedia_HasContractMultipartBodyLimit()
{
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit);
Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit);
}
[Fact]
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
{
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("The uploaded file must not exceed 200 MB.", error.Message);
Assert.Equal("Videos must be 100 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto()
{
// A .jpg with a foreign video type resolves to image/jpeg for validation, so it must
// also be sized as a photo, not given the 100 MB video allowance.
var file = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
{
var file = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Documents must be 50 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo()
{
var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]);
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("UnsupportedMediaType", error.Code);
storage.VerifyNoOtherCalls();
}
[Fact]
public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage()
{
Assert.Equal(
"Files must be 100 MB or smaller.",
WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown));
}
private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m'];
private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0];
private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D];
/// <summary>
/// A file that reports <paramref name="length"/> bytes but only backs the 512-byte header the
/// type rules read, so oversize cases run through real signature validation cheaply.
/// </summary>
private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header)
{
var bytes = new byte[512];
header.CopyTo(bytes, 0);
return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
private static FormFile VideoFormFile(int size, string fileName, string contentType)
{
var bytes = new byte[size];
// ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV.
bytes[4] = (byte)'f';
bytes[5] = (byte)'t';
bytes[6] = (byte)'y';
bytes[7] = (byte)'p';
bytes[8] = (byte)'i';
bytes[9] = (byte)'s';
bytes[10] = (byte)'o';
bytes[11] = (byte)'m';
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
private static (Mock<IWorkOrderMediaService> Service, Mock<IFileStoragePort> Storage) SetupSuccessfulAddMedia()
{
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
service
.Setup(candidate => candidate.EnsureCanMutateMediaAsync(
1, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), null))
.Returns(Task.CompletedTask);
service
.Setup(candidate => candidate.AddMediaAsync(
1, null, "https://storage.test/stored", It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage
.Setup(candidate => candidate.SaveFileAsync(It.IsAny<IFormFile>()))
.ReturnsAsync("https://storage.test/stored");
return (service, storage);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMp4_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
}
[Fact]
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
{
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("VideoTooLong", error.Code);
Assert.Equal("Videos must be 90 seconds or shorter.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", "");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType)
{
static byte[] Box(string type, byte[] body)
{
var box = new byte[8 + body.Length];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
body.CopyTo(box, 8);
return box;
}
var mvhd = new byte[20];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000);
var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
.Concat(Box("mdat", new byte[4096]))
.Concat(Box("moov", Box("mvhd", mvhd)))
.ToArray();
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
[Fact]
public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.MOV", "");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream");
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_HeicPhoto_IsAccepted()
{
var (service, storage) = SetupSuccessfulAddMedia();
var controller = CreateController(service, storage);
var bytes = new byte[512];
bytes[4] = (byte)'f';
bytes[5] = (byte)'t';
bytes[6] = (byte)'y';
bytes[7] = (byte)'p';
bytes[8] = (byte)'h';
bytes[9] = (byte)'e';
bytes[10] = (byte)'i';
bytes[11] = (byte)'c';
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic")
{
Headers = new HeaderDictionary(),
ContentType = "image/heic"
};
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
}
[Fact]
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
{
@ -167,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object);
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
@ -184,4 +443,37 @@ public class WorkOrderMediaServiceCancellationTests
mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once);
mediaData.VerifyNoOtherCalls();
}
[Fact]
public async Task GetMediaContent_ServesHeicAsImageHeic()
{
const string url = "https://example.test/Assets/Images/photo.heic";
var mediaData = new Mock<IWorkOrderMediaDataService>(MockBehavior.Strict);
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrder { Id = 1 });
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
var service = new WorkOrderMediaService(
mediaData.Object,
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
storage.Object,
new Mock<IUpliftDataService>(MockBehavior.Strict).Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "actor-1"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
},
"Test"));
var result = await service.GetMediaContentAsync(1, 10, user, "actor-1");
result.Content.Dispose();
Assert.Equal("image/heic", result.ContentType);
Assert.Equal("photo.heic", result.FileName);
}
}

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
/// come from 51 actions (Editworkorder binds two routes).
/// duplication is collapsed here, so this is the distinct action-relative contract. 53 routes
/// come from 52 actions (Editworkorder binds two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
@ -60,6 +60,7 @@ public class WorkOrderRouteContractTests
"GET board/search",
"GET completion-templates",
"GET completion-templates/{id:int}",
"GET completion-templates/{id:int}/linked-work-orders",
"GET lookups/dispatchers",
"GET {id:int}",
"GET {id:int}/audit",

View file

@ -33,16 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
if (result != null)
{
return Ok(new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
});
return Ok(LoginPayload.From(result));
}
return Unauthorized();
@ -55,20 +46,33 @@ namespace Api.SeaHavenIndustries.Controllers
}
[Authorize]
[Route("ChangePassword")]
[HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
{
// [Compare] already rejects this during model validation; the check here keeps the
// unconfirmed password from ever being set if that validation is bypassed.
if (!string.Equals(usermodel.Newpassword, usermodel.Confirmpassword, StringComparison.Ordinal))
return BadRequest(new Response { Status = "Password confirmation does not match", Message = "Passwords don't match" });
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
if (succeeded)
var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken);
return result.Status switch
{
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
}
else
return BadRequest(new Response { Status = "Old Password is incorrect" });
ChangePasswordStatus.Succeeded =>
Ok(new Response { Status = "Success ", Message = "Password successfully changed" }),
ChangePasswordStatus.PasswordRejected =>
BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }),
ChangePasswordStatus.Failed =>
BadRequest(new Response { Status = "Password not changed", Message = "Your password could not be changed. Try again." }),
_ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" })
};
}
private const string PasswordRequirementsMessage =
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.";
[HttpPost]
[Route("UpdateProfile")]
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)

View file

@ -33,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
scheduledTomorrow = stats.ScheduledTomorrow,
pendingUplifts = stats.PendingUplifts,
avetaPending = stats.AvetaPending,
unassigned = stats.Unassigned,
breakdown = stats.Breakdown,
dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount,

View file

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -105,6 +106,9 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = location.Email,
location.Status,
location.AccountId,
ClientName = location.AccountName,
location.AccountName,
location.Notes,
Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
};
@ -119,6 +123,10 @@ namespace Api.SeaHavenIndustries.Controllers
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
}
catch (SiteCodeConflictException)
{
return SiteCodeConflict();
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
@ -142,6 +150,10 @@ namespace Api.SeaHavenIndustries.Controllers
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
}
catch (SiteCodeConflictException)
{
return SiteCodeConflict();
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
@ -161,17 +173,73 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPatch("{id}/contact-info")]
public async Task<IActionResult> UpdateSiteContactInfo(int id, [FromBody] SiteContactInfoInput_DTO model, CancellationToken cancellationToken)
{
try
{
await _locationService.UpdateSiteContactInfoAsync(
id,
new SiteContactInfoRequestDTO { Contacts = MapSiteContacts(model.Contacts), Notes = model.Notes },
User,
cancellationToken);
return Ok(new DataResponse { Message = "Site Updated Successfully", Status = "200" });
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to edit this site." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Location not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("{id}/open-work-orders")]
public async Task<IActionResult> GetOpenWorkOrders(int id, CancellationToken cancellationToken)
{
try
{
var result = await _locationService.GetOpenWorkOrdersAsync(id, User, cancellationToken);
if (result == null)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
return Ok(result);
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to view this site." });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteLocation(int id, CancellationToken cancellationToken)
{
try
{
var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken);
var found = await _locationService.DeleteLocationByIdAsync(id, User, cancellationToken);
if (!found)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" });
}
catch (SiteForbiddenException forbidden)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = forbidden.Message });
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to delete this site." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Location not found" });
@ -188,6 +256,14 @@ namespace Api.SeaHavenIndustries.Controllers
return await DeleteLocation(id, cancellationToken);
}
private ObjectResult SiteCodeConflict() =>
Conflict(new
{
Status = "Conflict",
Message = SiteCodeConflictException.PublicMessage,
Code = SiteCodeConflictException.ErrorCode
});
private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model)
{
return new LocationCreateRequestDTO
@ -202,6 +278,7 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId(),
Notes = model.Notes,
Contacts = MapSiteContacts(model.Contacts)
};
}
@ -220,6 +297,7 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId(),
Notes = model.Notes,
Contacts = MapSiteContacts(model.Contacts)
};
}

View file

@ -11,10 +11,50 @@ namespace Api.SeaHavenIndustries.Controllers
public class NotificationsController : ControllerBase
{
private readonly INotificationFeedService _feedService;
private readonly ISlaBreachAcknowledgementService _slaAcknowledgement;
public NotificationsController(INotificationFeedService feedService)
public NotificationsController(
INotificationFeedService feedService,
ISlaBreachAcknowledgementService slaAcknowledgement)
{
_feedService = feedService;
_slaAcknowledgement = slaAcknowledgement;
}
/// <summary>
/// Acknowledges the missed response deadline of one work order in the caller's Notification Center.
/// 204 when recorded or already recorded; 404 when the caller's feed does not cover the work order;
/// 409 when its deadline has not been missed.
/// </summary>
[HttpPost("sla/{workOrderId:int}/acknowledge")]
public async Task<IActionResult> AcknowledgeSlaBreach(int workOrderId, CancellationToken cancellationToken)
{
try
{
var outcome = await _slaAcknowledgement.AcknowledgeAsync(User, workOrderId, cancellationToken);
return outcome switch
{
SlaBreachAcknowledgementOutcome.NotFound => NotFound(new
{
code = "NotFound",
message = "Work order not found."
}),
SlaBreachAcknowledgementOutcome.NotBreached => Conflict(new
{
code = "NotBreached",
message = "This work order has not missed its response deadline."
}),
_ => NoContent()
};
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
}
[HttpGet]

View file

@ -11,10 +11,39 @@ namespace Api.SeaHavenIndustries.Controllers;
public sealed class TeamMemberController : ControllerBase
{
private readonly ITeamMemberService _teamMemberService;
private readonly ITeamPermissionService _permissionService;
private readonly ITeamMemberInviteService _inviteService;
public TeamMemberController(ITeamMemberService teamMemberService)
public TeamMemberController(
ITeamMemberService teamMemberService,
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
{
_teamMemberService = teamMemberService;
_permissionService = permissionService;
_inviteService = inviteService;
}
[HttpGet("me/permissions")]
public async Task<IActionResult> GetMyPermissions(CancellationToken cancellationToken)
{
var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : Unauthorized();
}
[HttpPost("{userId}/invite")]
public async Task<IActionResult> ResendInvite(string userId, CancellationToken cancellationToken)
{
var outcome = await _inviteService.ResendAsync(userId, User, cancellationToken);
if (outcome.Success)
return Ok(new { message = "Invite sent" });
return outcome.Error switch
{
"Forbidden" => Forbid(),
"Team member not found." => NotFound(new { message = outcome.Error }),
_ => BadRequest(new { message = outcome.Error })
};
}
[HttpPost]

View file

@ -0,0 +1,95 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers;
/// <summary>
/// Anonymous invite registration. The invite token travels only in request bodies,
/// and every failure maps to a fixed public message.
/// </summary>
[AllowAnonymous]
[ApiController]
[Route("api/team-member-invites")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[TypeFilter(typeof(InviteRegistrationExceptionFilter))]
public sealed class TeamMemberInviteController : ControllerBase
{
public const string InvalidInviteMessage =
"This invite link is invalid or has expired. Ask your admin to send a new invite.";
private readonly ITeamMemberRegistrationService _registrationService;
public TeamMemberInviteController(ITeamMemberRegistrationService registrationService)
{
_registrationService = registrationService;
}
[HttpPost("resolve")]
public async Task<IActionResult> Resolve(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.ResolveAsync(request.Token, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok ? Ok(outcome.Details) : Failure(outcome);
}
[HttpPost("send-code")]
public async Task<IActionResult> SendCode(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.SendCodeAsync(request.Token, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok
? Ok(new { message = "Code sent" })
: Failure(outcome);
}
[HttpPost("verify-code")]
public async Task<IActionResult> VerifyCode(VerifyTeamMemberInviteCodeRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.VerifyCodeAsync(request.Token, request.Code, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok
? Ok(new { message = "Email confirmed" })
: Failure(outcome);
}
[HttpPost("complete")]
public async Task<IActionResult> Complete(CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken)
{
var outcome = await _registrationService.CompleteAsync(request, cancellationToken);
return outcome.Status == TeamMemberRegistrationStatus.Ok && outcome.Session is not null
? Ok(LoginPayload.From(outcome.Session))
: Failure(outcome);
}
private IActionResult Failure(TeamMemberRegistrationOutcomeDTO outcome)
{
var (statusCode, code, message) = outcome.Status switch
{
TeamMemberRegistrationStatus.CodeIncorrect =>
(StatusCodes.Status400BadRequest, "code_incorrect", "Incorrect code — check your email and try again"),
TeamMemberRegistrationStatus.CodeExpired =>
(StatusCodes.Status400BadRequest, "code_expired", "This code has expired — request a new code"),
TeamMemberRegistrationStatus.CodeLocked =>
(StatusCodes.Status400BadRequest, "code_locked", "Too many incorrect attempts — request a new code"),
TeamMemberRegistrationStatus.ResendTooSoon =>
(StatusCodes.Status429TooManyRequests, "resend_too_soon", "Please wait a moment before requesting another code"),
TeamMemberRegistrationStatus.ResendLimitReached =>
(StatusCodes.Status429TooManyRequests, "resend_limit_reached", "Too many codes were requested. Ask your admin to send a new invite."),
TeamMemberRegistrationStatus.CodeDeliveryFailed =>
(StatusCodes.Status503ServiceUnavailable, "code_delivery_failed", "We couldn't send the code. Try again in a minute."),
TeamMemberRegistrationStatus.EmailNotConfirmed =>
(StatusCodes.Status400BadRequest, "email_not_confirmed", "Confirm your email before finishing registration"),
TeamMemberRegistrationStatus.PasswordRejected =>
(StatusCodes.Status400BadRequest, "password_rejected", "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."),
TeamMemberRegistrationStatus.InvalidPhone =>
(StatusCodes.Status400BadRequest, "invalid_phone", "Enter a valid phone number"),
_ => (StatusCodes.Status400BadRequest, "invalid_invite", InvalidInviteMessage)
};
if (outcome.RetryAfterSeconds is int retryAfter)
Response.Headers.RetryAfter = retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture);
return StatusCode(statusCode, new { code, message, retryAfterSeconds = outcome.RetryAfterSeconds });
}
}

View file

@ -53,6 +53,7 @@ public sealed class TeamPermissionController : ControllerBase
return result.Status switch
{
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
TeamPermissionResultStatus.Unauthorized => new UnauthorizedResult(),
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
new Response { Status = "Error", Message = "User not found." }),

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -299,7 +300,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("dispatches/{id:int}/completion-documents")]
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(10_000_000)]
[RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)]
public async Task<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,

View file

@ -18,83 +18,111 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderCompletionController : Controller
{
private readonly IWorkOrderCompletionService _workOrderCompletionService;
private readonly ICompletionDocTemplateService _completionDocTemplateService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderCompletionController(
IWorkOrderCompletionService workOrderCompletionService,
ICompletionDocTemplateService completionDocTemplateService,
IFileStoragePort fileStorage)
{
_workOrderCompletionService = workOrderCompletionService;
_completionDocTemplateService = completionDocTemplateService;
_fileStorage = fileStorage;
}
[HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? search = null,
[FromQuery] string? serviceKey = null,
[FromQuery] WorkOrderType? workOrderType = null)
[FromQuery] WorkOrderType? workOrderType = null,
CancellationToken cancellationToken = default)
{
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
var templates = await _completionDocTemplateService.ListAsync(search, serviceKey, workOrderType, cancellationToken);
return Ok(templates);
}
[HttpGet("completion-templates/{id:int}")]
public async Task<IActionResult> GetCompletionTemplate(int id)
public async Task<IActionResult> GetCompletionTemplate(int id, CancellationToken cancellationToken)
{
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
var template = await _completionDocTemplateService.GetAsync(id, cancellationToken);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found." });
return Ok(template);
}
[Authorize(Roles = "Admin")]
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
[HttpGet("completion-templates/{id:int}/linked-work-orders")]
public async Task<IActionResult> GetCompletionTemplateLinkedWorkOrders(int id, CancellationToken cancellationToken)
{
try
{
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
var linked = await _completionDocTemplateService.GetLinkedWorkOrdersAsync(User, id, cancellationToken);
return Ok(linked);
}
catch (WorkOrderBoardValidationException ex)
{
return MapTemplateError(ex);
}
}
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate(
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
try
{
var created = await _completionDocTemplateService.CreateAsync(User, request, cancellationToken);
return Ok(created);
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
[Authorize(Roles = "Admin")]
[HttpPut("completion-templates/{id:int}")]
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
public async Task<IActionResult> UpdateCompletionTemplate(
int id,
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
try
{
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
var updated = await _completionDocTemplateService.UpdateAsync(User, id, request, cancellationToken);
return Ok(updated);
}
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 });
return MapTemplateError(ex);
}
}
[Authorize(Roles = "Admin")]
[HttpDelete("completion-templates/{id:int}")]
public async Task<IActionResult> DeleteCompletionTemplate(int id)
public async Task<IActionResult> DeleteCompletionTemplate(int id, CancellationToken cancellationToken)
{
try
{
await _workOrderCompletionService.DeleteTemplateAsync(id);
await _completionDocTemplateService.DeleteAsync(User, id, cancellationToken);
return NoContent();
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
catch (WorkOrderBoardValidationException ex)
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
private IActionResult MapTemplateError(WorkOrderBoardValidationException ex)
{
var body = new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message };
return ex.Code switch
{
"Forbidden" => StatusCode(StatusCodes.Status403Forbidden, body),
"NotFound" => NotFound(body),
_ => UnprocessableEntity(body)
};
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(50_000_000)]
public async Task<IActionResult> UploadCompletionDoc(

View file

@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/workorders")]
public class WorkOrderMediaController : Controller
{
public const long MaxUploadBytes = 200_000_000;
public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes;
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IFileStoragePort _fileStorage;
@ -88,14 +88,28 @@ namespace Api.SeaHavenIndustries.Controllers
string? fileUrl = null;
try
{
if (file.Length > MaxUploadBytes)
// Type first, so an unsupported file always reports UnsupportedMediaType instead
// of being sized under a kind it does not have.
WorkOrderMediaFileRules.EnsureAllowed(file, category);
// Media contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
// Classify by the same resolved type EnsureAllowed validates against.
var sizeMessage = WorkOrderMediaContract.ValidateSize(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length);
if (sizeMessage != null)
{
throw new WorkOrderBoardValidationException(
"FileTooLarge",
"The uploaded file must not exceed 200 MB.");
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
}
WorkOrderMediaFileRules.EnsureAllowed(file, category);
if (WorkOrderMediaContract.ResolveKind(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
== WorkOrderMediaContract.UploadKind.Video)
{
using var content = file.OpenReadStream();
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
if (durationMessage != null)
throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage);
}
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);

View file

@ -16,6 +16,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public string? Notes { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -11,6 +11,13 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Phone { get; set; }
}
/// <summary>Contacts and notes edited from the work-order Site dialog.</summary>
public class SiteContactInfoInput_DTO
{
public List<SiteContactInput_DTO>? Contacts { get; set; }
public string? Notes { get; set; }
}
public class Location_DTO
{
public string? Title { get; set; }
@ -24,6 +31,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public string? Notes { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -0,0 +1,24 @@
using SeaHaven.Services.DTOs;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>The session payload the web app stores after sign-in.</summary>
public static class LoginPayload
{
public static object From(LoginResultDTO result)
{
ArgumentNullException.ThrowIfNull(result);
return new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
};
}
}
}

View file

@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
namespace Api.SeaHavenIndustries.Filters
{
/// <summary>
/// Anonymous invite endpoints never echo an exception: controller-scoped exception
/// filters run before the global ones, so no message, stack or SQL reaches the caller.
/// Cancellation is left to the host.
/// </summary>
public sealed class InviteRegistrationExceptionFilter : IExceptionFilter
{
public const string Message = "Something went wrong. Try again in a minute.";
private readonly ILogger<InviteRegistrationExceptionFilter> _logger;
public InviteRegistrationExceptionFilter(ILogger<InviteRegistrationExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
if (context.Exception is OperationCanceledException)
return;
_logger.LogError(context.Exception, "Invite registration request failed.");
context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null })
{
StatusCode = StatusCodes.Status500InternalServerError
};
context.ExceptionHandled = true;
}
}
}

View file

@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
private readonly ILogger<SendMessage> _logger;
//string keysapi = "";
public SendMessage(IConfiguration configuration)
public SendMessage(IConfiguration configuration, ILogger<SendMessage> logger)
{
_configuration = configuration;
_logger = logger;
//keysapi = _configuration.GetValue<string>("SendGrid:ApiKey");
}
protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey);
/// <summary>
/// SendGrid reports a rejected message through the status code, not an exception.
/// Only the status is logged: never the recipient, subject or response body.
/// </summary>
private bool Delivered(Response response)
{
if (response.IsSuccessStatusCode)
return true;
_logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode);
return false;
}
private bool Failed(Exception ex)
{
_logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name);
return false;
}
public async Task<bool> SendEMail(string emailTo, string subject, string body)
{
try
@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper
var htmlContent = body;
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
var response = await client.SendEmailAsync(msg);
var dd = response.Body.ReadAsStringAsync();
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
public async Task<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
//var apiKey = "<SENDGRID_API_KEY>";
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.Attachments = new List<SendGrid.Helpers.Mail.Attachment> { attachment };
var response = await client.SendEmailAsync(msg);
var dd = response.Body.ReadAsStringAsync();
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
public async Task<bool> SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper
try
{
var apiKey = _configuration.GetValue<string>("SendGrid:ApiKey");
var client = new SendGridClient(apiKey);
var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries");
var to = new EmailAddress(emailTo, "");
@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.SetReplyTo(new EmailAddress(replyTo));
var response = await client.SendEmailAsync(msg);
return true;
return Delivered(response);
}
catch (Exception ex)
{
return false;
return Failed(ex);
}
}
}

View file

@ -0,0 +1,24 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
namespace Api.SeaHavenIndustries.Infrastructure
{
public static class IdentityRegistration
{
/// <summary>
/// Registers ASP.NET Identity for the API with the shared password policy.
/// Program.cs and the behavior tests both compose Identity through this
/// method so the rule under test is the rule that runs.
/// </summary>
public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services)
{
return services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
IdentityPasswordPolicy.Apply(options.Password);
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
}
}
}

View file

@ -1,5 +1,6 @@
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Observability;
using Api.SeaHavenIndustries.Options;
@ -43,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddSeaHavenIdentity();
builder.Services.AddControllers(options =>
{

View file

@ -54,6 +54,23 @@ namespace Data.SeaHavenIndustries
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<CompletionDocTemplate>()
.Property(t => t.ExtraSafetyNote)
.HasMaxLength(CompletionDocTemplate.ExtraSafetyNoteMaxLength);
builder.Entity<CompletionDocTemplateProcedure>(entity =>
{
entity.Property(p => p.Name)
.HasMaxLength(CompletionDocTemplateProcedure.NameMaxLength);
entity.HasOne(p => p.CompletionDocTemplate)
.WithMany(t => t.Procedures)
.HasForeignKey(p => p.CompletionDocTemplateId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => new { p.CompletionDocTemplateId, p.SortOrder });
});
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
@ -311,6 +328,21 @@ namespace Data.SeaHavenIndustries
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
});
builder.Entity<TeamMemberInvite>(entity =>
{
entity.HasIndex(invite => invite.TokenHash)
.IsUnique()
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
entity.HasIndex(invite => invite.UserId)
.HasDatabaseName("IX_TeamMemberInvites_UserId");
entity.HasOne(invite => invite.User)
.WithMany()
.HasForeignKey(invite => invite.UserId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<UserServiceArea>(entity =>
{
entity.HasKey(area => new { area.UserId, area.Area });
@ -344,6 +376,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<CompletionDocTemplateProcedure> CompletionDocTemplateProcedures { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
@ -388,6 +421,7 @@ namespace Data.SeaHavenIndustries
public DbSet<Region> Regions { get; set; }
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
public DbSet<TeamMemberInvite> TeamMemberInvites { get; set; }
public override int SaveChanges()
{

View file

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Identity;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// The single password rule for every surface that sets a password: at least
/// six characters with one uppercase letter, one number, and one special
/// character. Lowercase letters are deliberately not required so the server
/// accepts exactly what the four-item checklist in the web app marks as met.
/// </summary>
public static class IdentityPasswordPolicy
{
public const int MinimumLength = 6;
public static void Apply(PasswordOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.RequiredLength = MinimumLength;
options.RequireUppercase = true;
options.RequireDigit = true;
options.RequireNonAlphanumeric = true;
options.RequireLowercase = false;
options.RequiredUniqueChars = 1;
}
private static readonly HashSet<string> PolicyErrorCodes = new(StringComparer.Ordinal)
{
nameof(IdentityErrorDescriber.PasswordTooShort),
nameof(IdentityErrorDescriber.PasswordRequiresUpper),
nameof(IdentityErrorDescriber.PasswordRequiresLower),
nameof(IdentityErrorDescriber.PasswordRequiresDigit),
nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric),
nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars)
};
/// <summary>
/// True when Identity refused the password itself. Other failures, such as a
/// concurrency conflict, must not be reported to the user as a weak password.
/// </summary>
public static bool IsPolicyRejection(IdentityResult result)
{
ArgumentNullException.ThrowIfNull(result);
return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code));
}
}
}

View file

@ -35,6 +35,8 @@ namespace Data.SeaHavenIndustries.Enums
Inspection = 5,
Reactive = 6,
AddOn = 7,
/// <summary>Client never responded after the due date passed. Distinct from the derived past-due overlay.</summary>
Overdue = 8,
Other = 99
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class CompletionDocTemplateProcedures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.CreateTable(
name: "CompletionDocTemplateProcedures",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CompletionDocTemplateProcedures", x => x.Id);
table.ForeignKey(
name: "FK_CompletionDocTemplateProcedures_CompletionDocTemplates_CompletionDocTemplateId",
column: x => x.CompletionDocTemplateId,
principalTable: "CompletionDocTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CompletionDocTemplateProcedures_CompletionDocTemplateId_SortOrder",
table: "CompletionDocTemplateProcedures",
columns: new[] { "CompletionDocTemplateId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompletionDocTemplateProcedures");
migrationBuilder.DropColumn(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddLocationNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Locations",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Notes",
table: "Locations");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddTeamMemberInvites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeamMemberInvites",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
TokenHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UsedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RevokedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
CodeSalt = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
CodeExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeFailedAttempts = table.Column<int>(type: "int", nullable: false),
CodeSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CodeSendCount = table.Column<int>(type: "int", nullable: false),
EmailConfirmedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMemberInvites", x => x.Id);
table.ForeignKey(
name: "FK_TeamMemberInvites_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_TeamMemberInvites_TokenHash",
table: "TeamMemberInvites",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeamMemberInvites_UserId",
table: "TeamMemberInvites",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeamMemberInvites");
}
}
}

View file

@ -523,6 +523,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("ExtraSafetyNote")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@ -560,6 +564,36 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("CompletionDocTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompletionDocTemplateId")
.HasColumnType("int");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CompletionDocTemplateId", "SortOrder");
b.ToTable("CompletionDocTemplateProcedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.Property<int>("Id")
@ -1582,6 +1616,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
@ -2067,6 +2104,71 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("TaskListTemplateItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CodeExpiresAt")
.HasColumnType("datetime2");
b.Property<int>("CodeFailedAttempts")
.HasColumnType("int");
b.Property<string>("CodeHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CodeSalt")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("CodeSendCount")
.HasColumnType("int");
b.Property<DateTime?>("CodeSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime?>("UsedAt")
.HasColumnType("datetime2");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
b.HasIndex("UserId")
.HasDatabaseName("IX_TeamMemberInvites_UserId");
b.ToTable("TeamMemberInvites");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
{
b.Property<int>("Id")
@ -3481,6 +3583,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
.WithMany("Procedures")
.HasForeignKey("CompletionDocTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CompletionDocTemplate");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact")
@ -3785,6 +3898,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Template");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
@ -4108,6 +4232,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrderCategories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Navigation("Procedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
{
b.Navigation("WorkOrderContacts");

View file

@ -4,10 +4,32 @@ namespace Data.SeaHavenIndustries
{
public class CompletionDocTemplate : FullAuditEntity
{
public const int ExtraSafetyNoteMaxLength = 2000;
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
// At most one template-specific note, printed after the standard
// safety bullets every generated document carries.
public string? ExtraSafetyNote { get; set; }
public virtual ICollection<CompletionDocTemplateProcedure> Procedures { get; set; } = new List<CompletionDocTemplateProcedure>();
}
// Ordered procedure step of a completion document template. SortOrder is the
// position the author gave it; documents print procedures in that order.
public class CompletionDocTemplateProcedure
{
public const int NameMaxLength = 200;
public int Id { get; set; }
public int CompletionDocTemplateId { get; set; }
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
public int SortOrder { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
}
}

View file

@ -27,6 +27,9 @@ namespace Data.SeaHavenIndustries
public string? ExternalSource { get; set; }
public string? ExternalLocationId { get; set; }
/// <summary>Free-text site notes, edited from the work-order Site dialog.</summary>
public string? Notes { get; set; }
// Navigation Properties
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? workOrders { get; set; }

View file

@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// A single-use registration invite for one pending team member. Only hashes of
/// the invite token and of the email confirmation code are stored.
/// </summary>
public class TeamMemberInvite
{
public int Id { get; set; }
[Required]
[MaxLength(450)]
public string UserId { get; set; } = string.Empty;
[ForeignKey(nameof(UserId))]
public virtual ApplicationUser? User { get; set; }
/// <summary>Lowercase hex SHA-256 of the raw invite token.</summary>
[Required]
[MaxLength(64)]
public string TokenHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? UsedAt { get; set; }
public DateTime? RevokedAt { get; set; }
/// <summary>Lowercase hex SHA-256 of the salt followed by the confirmation code.</summary>
[MaxLength(64)]
public string? CodeHash { get; set; }
[MaxLength(32)]
public string? CodeSalt { get; set; }
public DateTime? CodeExpiresAt { get; set; }
public int CodeFailedAttempts { get; set; }
public DateTime? CodeSentAt { get; set; }
public int CodeSendCount { get; set; }
public DateTime? EmailConfirmedAt { get; set; }
}
}

View file

@ -45,6 +45,9 @@ namespace Data.SeaHavenIndustries
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
// True when the vendor raised the request in the portal; false when it was raised
// from the work order. Only vendor-raised requests are revisable or withdrawable.
public bool RaisedByVendor { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }

View file

@ -21,6 +21,12 @@ 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.
Stored created, modified and deletion times are UTC: the API stamps them with
`DateTime.UtcNow`, whatever the host's time zone. The API has only ever run on
Linux Elastic Beanstalk hosts left at their UTC default (nothing in Terraform,
`.ebextensions` or `.platform` sets a time zone), so rows written before the
switch from `DateTime.Now` are already UTC and need no backfill.
## Architecture
```text

View file

@ -0,0 +1,36 @@
namespace SeaHaven.DataServices.Dto
{
public sealed class TeamMemberInviteData
{
public required int Id { get; init; }
public required string UserId { get; init; }
public DateTime ExpiresAt { get; init; }
public DateTime? UsedAt { get; init; }
public DateTime? RevokedAt { get; init; }
public string? CodeHash { get; init; }
public string? CodeSalt { get; init; }
public DateTime? CodeExpiresAt { get; init; }
public int CodeFailedAttempts { get; init; }
public DateTime? CodeSentAt { get; init; }
public int CodeSendCount { get; init; }
public DateTime? EmailConfirmedAt { get; init; }
}
/// <summary>
/// Replaces the confirmation code on an open invite when the resend cooldown has
/// elapsed and the send limit has not been reached.
/// </summary>
public sealed class StartTeamMemberInviteCodeCommand
{
public required int InviteId { get; init; }
public required string CodeHash { get; init; }
public required string CodeSalt { get; init; }
public required DateTime Now { get; init; }
public required DateTime CodeExpiresAt { get; init; }
/// <summary>A code may be replaced only when the previous one was sent at or before this instant.</summary>
public required DateTime LastSentNoLaterThan { get; init; }
public required int MaxSends { get; init; }
}
}

View file

@ -38,6 +38,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNameSnapshot,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
SiteNotes = w.Locations != null ? w.Locations.Notes : null,
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
@ -134,7 +135,7 @@ namespace SeaHaven.DataServices.Helpers
var pocPhone = primaryFrozenPoc?.Phone
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
var pocNotes = frozenPoc?.Notes
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes, w.SiteNotes);
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);

View file

@ -5,6 +5,14 @@ namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardQueryFilters
{
/// <summary>
/// The window the Work Orders board searches when no date range is selected
/// (client ADVANCED_SEARCH_ALL_WEEKS_FROM/TO), used with includeDateless.
/// Counts that drill into that list use it too, so both see the same rows.
/// </summary>
public static readonly DateOnly AllWeeksFrom = new(2000, 1, 1);
public static readonly DateOnly AllWeeksTo = new(2099, 12, 31);
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
@ -50,9 +58,9 @@ namespace SeaHaven.DataServices.Helpers
}
/// <summary>
/// Filters by real <paramref name="types"/> (including <see cref="WorkOrderType.Other"/>)
/// and/or past-due rows when <paramref name="overdue"/> is true.
/// Types and overdue are combined with OR when both are present.
/// Filters by stored <paramref name="types"/> (including <see cref="WorkOrderType.Overdue"/> and
/// <see cref="WorkOrderType.Other"/>) and/or past-due rows when <paramref name="overdue"/> is true.
/// Past due is a status overlay, so types and overdue are combined with AND when both are present.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query,
@ -86,8 +94,8 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn));
&& ((w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|| (includeAddOnFlag && w.IsAddOn)));
}
return query.Where(w =>
@ -155,6 +163,44 @@ namespace SeaHaven.DataServices.Helpers
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
}
/// <summary>
/// Lower-cased legacy status text and the lifecycle status it stands for:
/// the Phase0 backfill rules, kept equal to LifecycleStatusMapper.FromLegacyStatus.
/// </summary>
public static readonly IReadOnlyDictionary<string, LifecycleStatus> LegacyStatusValues =
new Dictionary<string, LifecycleStatus>(StringComparer.Ordinal)
{
["open"] = LifecycleStatus.Incomplete,
["incomplete"] = LifecycleStatus.Incomplete,
["pending"] = LifecycleStatus.Pending,
["onhold"] = LifecycleStatus.Pending,
["on hold"] = LifecycleStatus.Pending,
["scheduled"] = LifecycleStatus.Scheduled,
["en route"] = LifecycleStatus.EnRoute,
["enroute"] = LifecycleStatus.EnRoute,
["on site"] = LifecycleStatus.OnSite,
["onsite"] = LifecycleStatus.OnSite,
["inprogress"] = LifecycleStatus.InProgress,
["in progress"] = LifecycleStatus.InProgress,
["completed"] = LifecycleStatus.Completed,
["complete"] = LifecycleStatus.Completed,
["done"] = LifecycleStatus.Completed,
["closed"] = LifecycleStatus.Completed,
["rescheduled"] = LifecycleStatus.Rescheduled,
["cancelled"] = LifecycleStatus.Canceled,
["canceled"] = LifecycleStatus.Canceled,
["pending quote"] = LifecycleStatus.PendingQuote,
["pendingquote"] = LifecycleStatus.PendingQuote,
["pendingapproval"] = LifecycleStatus.PendingQuote,
["pending approval"] = LifecycleStatus.PendingQuote,
};
/// <summary>
/// Keeps rows in <paramref name="statuses"/>. Rows with no LifecycleStatus
/// (legacy create paths still write only Status) match by their legacy
/// status, read as the Phase0 backfill does: known text maps through
/// <see cref="LegacyStatusValues"/>, anything else, blank included, is Incomplete.
/// </summary>
public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)
@ -162,7 +208,22 @@ namespace SeaHaven.DataServices.Helpers
if (statuses == null || statuses.Count == 0)
return query;
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
var legacyValues = LegacyStatusValues
.Where(entry => statuses.Contains(entry.Value))
.Select(entry => entry.Key)
.ToList();
var knownLegacyValues = LegacyStatusValues.Keys.ToList();
var includeUnknownLegacy = statuses.Contains(LifecycleStatus.Incomplete);
// Null check before Trim(): EF translates it, the in-memory provider runs it.
return query.Where(w =>
(w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value))
|| (w.LifecycleStatus == null
&& (((w.LegacyStatus ?? w.Status) != null
&& legacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower()))
|| (includeUnknownLegacy
&& ((w.LegacyStatus ?? w.Status) == null
|| !knownLegacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower()))))));
}
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
@ -220,13 +281,26 @@ namespace SeaHaven.DataServices.Helpers
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
/// <summary>
/// Narrows to rows whose Schedule On date falls in the range, or, for
/// week-only rows, whose Target Week overlaps it: the same date the
/// weekly board groups by (SH-391). Rows with no date never match a
/// range; they are added only when <paramref name="includeDateless"/>
/// is set, which callers send for searches with no date range selected.
/// Completed and canceled dateless rows stay out, matching
/// <see cref="ApplyUnscheduledOnlyFilter"/>.
/// </summary>
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> query,
DateOnly dateFrom,
DateOnly dateTo)
DateOnly dateTo,
bool includeDateless = false)
{
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
// A target week (stored as its Monday) overlaps the range when it
// starts by the range end and its Sunday is on or after the range start.
var earliestWeekStart = dateFrom.DayNumber >= 6 ? dateFrom.AddDays(-6) : dateFrom;
return query.Where(w =>
(w.ScheduledDate != null
@ -234,9 +308,10 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date <= toDate)
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= dateFrom
&& w.TargetWeek >= earliestWeekStart
&& w.TargetWeek <= dateTo)
|| (w.ScheduledDate == null
|| (includeDateless
&& w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled));
}

View file

@ -57,6 +57,7 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Overdue && "overdue".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))

View file

@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Accounts> AddAsync(Accounts account)
{
account.CreatedDate = DateTime.Now;
account.CreatedDate = DateTime.UtcNow;
await _context.Accounts.AddAsync(account);
await _context.SaveChangesAsync();
return account;
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Accounts account)
{
account.LastModificationTime = DateTime.Now;
account.LastModificationTime = DateTime.UtcNow;
_context.Accounts.Update(account);
await _context.SaveChangesAsync();
}

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Assets> AddAsync(Assets asset)
{
asset.CreatedDate = DateTime.Now;
asset.CreatedDate = DateTime.UtcNow;
await _context.Assets.AddAsync(asset);
await _context.SaveChangesAsync();
return asset;
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Assets asset)
{
asset.LastModificationTime = DateTime.Now;
asset.LastModificationTime = DateTime.UtcNow;
_context.Assets.Update(asset);
await _context.SaveChangesAsync();
}

View file

@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Category> AddAsync(Category category)
{
category.CreatedDate = DateTime.Now;
category.CreatedDate = DateTime.UtcNow;
await _context.Categories.AddAsync(category);
await _context.SaveChangesAsync();
return category;
@ -33,7 +33,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Category category)
{
category.LastModificationTime = DateTime.Now;
category.LastModificationTime = DateTime.UtcNow;
_context.Categories.Update(category);
await _context.SaveChangesAsync();
}

View file

@ -42,6 +42,7 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Where(n => n.IsDeleted != true)
.Select(n => new Locations
{
Id = n.Id,

View file

@ -1,12 +1,17 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
{
private static readonly LifecycleStatus[] OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
.ToArray();
private readonly ApplicationDbContext _context;
public CompletionDocTemplateDataService(ApplicationDbContext context)
@ -14,33 +19,6 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
return await query
.OrderBy(t => t.Name)
.Select(MapRow)
.ToListAsync();
}
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
{
if (!string.IsNullOrWhiteSpace(trade))
@ -70,33 +48,149 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
public async Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim().ToLower();
query = query.Where(t => t.Name.ToLower().Contains(term));
}
return await ProjectDetail(query).ToListAsync(cancellationToken);
}
public async Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true);
return await ProjectDetail(query).FirstOrDefaultAsync(cancellationToken);
}
public Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates
.Include(t => t.Procedures)
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public async Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken)
{
template.CreatedDate = DateTime.UtcNow;
await _context.CompletionDocTemplates.AddAsync(template);
await _context.SaveChangesAsync();
return template;
await _context.CompletionDocTemplates.AddAsync(template, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return template.Id;
}
public async Task UpdateAsync(CompletionDocTemplate template)
public async Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken)
{
if (replacementProcedures != null)
{
// Explicit removal: the context forces Restrict on every FK before the
// procedure cascade is configured, so orphan deletion is not relied on.
_context.CompletionDocTemplateProcedures.RemoveRange(template.Procedures.ToList());
foreach (var procedure in replacementProcedures)
{
procedure.CompletionDocTemplateId = template.Id;
_context.CompletionDocTemplateProcedures.Add(procedure);
}
}
template.LastModificationTime = DateTime.UtcNow;
_context.CompletionDocTemplates.Update(template);
await _context.SaveChangesAsync();
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> DeleteAsync(int id)
public async Task<bool> DeleteAndUnlinkServicesAsync(
int id,
string? deleterUserId,
CancellationToken cancellationToken)
{
var entity = await _context.CompletionDocTemplates.FindAsync(id);
if (entity == null)
var template = await _context.CompletionDocTemplates
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
if (template == null)
return false;
entity.IsDeleted = true;
entity.DeletionTime = DateTime.UtcNow;
await _context.SaveChangesAsync();
var now = DateTime.UtcNow;
template.IsDeleted = true;
template.DeletionTime = now;
template.DeleterUserId = deleterUserId;
var linkedServices = await _context.Services
.Where(s => s.CompletionDocTemplateId == id)
.ToListAsync(cancellationToken);
foreach (var service in linkedServices)
{
service.CompletionDocTemplateId = null;
service.LastModificationTime = now;
}
await _context.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value);
var linkedServiceIds = _context.Services
.Where(s => s.CompletionDocTemplateId == templateId)
.Select(s => (int?)s.Id);
// Shared status filter so legacy rows without a LifecycleStatus are read
// through their legacy status text (a legacy "completed" row is not open).
workOrders = WorkOrderBoardQueryFilters.ApplyStatusFilter(workOrders, OpenStatuses);
return await workOrders
.Where(w => w.ServiceId != null && linkedServiceIds.Contains(w.ServiceId))
.OrderBy(w => w.Id)
.Select(w => w.Id)
.ToListAsync(cancellationToken);
}
private IQueryable<CompletionDocTemplateDetailRow> ProjectDetail(IQueryable<CompletionDocTemplate> templates)
=> from t in templates
join u in _context.Users on t.createdby equals u.Id into creators
from creator in creators.DefaultIfEmpty()
orderby t.Name, t.Id
select new CompletionDocTemplateDetailRow(
t.Id,
t.Name,
t.ServiceKey,
t.WorkOrderType,
t.TemplateUrl,
t.IsActive,
t.ExtraSafetyNote,
t.CreatedDate,
t.LastModificationTime,
creator != null ? creator.FirstName : null,
creator != null ? creator.LastName : null,
t.Procedures
.OrderBy(p => p.SortOrder)
.ThenBy(p => p.Id)
.Select(p => new CompletionDocTemplateProcedureRow(p.Id, p.SortOrder, p.Name, p.Description))
.ToList());
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}

View file

@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Contacts> AddAsync(Contacts contact)
{
contact.CreatedDate = DateTime.Now;
contact.CreatedDate = DateTime.UtcNow;
await _context.Contacts.AddAsync(contact);
await _context.SaveChangesAsync();
return contact;
@ -108,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Contacts contact)
{
contact.LastModificationTime = DateTime.Now;
contact.LastModificationTime = DateTime.UtcNow;
_context.Contacts.Update(contact);
await _context.SaveChangesAsync();
}
@ -130,15 +130,15 @@ namespace SeaHaven.DataServices.Implementation
public async Task<bool> EmailExistsAsync(string email, int? excludeId = null)
{
return await _context.Contacts.AnyAsync(c =>
c.Email == email &&
return await _context.Contacts.AnyAsync(c =>
c.Email == email &&
(excludeId == null || c.Id != excludeId));
}
public async Task<bool> PhoneExistsAsync(string phone, int? excludeId = null)
{
return await _context.Contacts.AnyAsync(c =>
c.PhoneNumber == phone &&
return await _context.Contacts.AnyAsync(c =>
c.PhoneNumber == phone &&
(excludeId == null || c.Id != excludeId));
}

View file

@ -39,6 +39,41 @@ namespace SeaHaven.DataServices.Implementation
};
}
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
.GetValues<LifecycleStatus>()
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
.ToList();
public async Task<int> CountOpenUnassignedAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken)
{
// A dispatcher-scoped Dashboard has no unassigned work by definition.
if (dispatcherId != null)
return 0;
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null);
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
// All time counts what the board lists with no range: every week plus
// undated work, so the tile and its drill-down agree.
query = dateFrom is DateOnly from && dateTo is DateOnly to
? WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to)
: WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
query,
WorkOrderBoardQueryFilters.AllWeeksFrom,
WorkOrderBoardQueryFilters.AllWeeksTo,
includeDateless: true);
return await query.CountAsync(cancellationToken);
}
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,

View file

@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Dispatch> AddAsync(Dispatch dispatch)
{
dispatch.CreatedDate = DateTime.Now;
dispatch.CreatedDate = DateTime.UtcNow;
await _context.Dispatches.AddAsync(dispatch);
await _context.SaveChangesAsync();
return dispatch;
@ -93,7 +93,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Dispatch dispatch)
{
dispatch.LastModificationTime = DateTime.Now;
dispatch.LastModificationTime = DateTime.UtcNow;
_context.Dispatches.Update(dispatch);
await _context.SaveChangesAsync();
}

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Employee> AddAsync(Employee employee)
{
employee.CreatedDate = DateTime.Now;
employee.CreatedDate = DateTime.UtcNow;
await _context.Employees.AddAsync(employee);
await _context.SaveChangesAsync();
return employee;
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Employee employee)
{
employee.LastModificationTime = DateTime.Now;
employee.LastModificationTime = DateTime.UtcNow;
_context.Employees.Update(employee);
await _context.SaveChangesAsync();
}

View file

@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<FollowUps> AddAsync(FollowUps followUp)
{
followUp.CreatedDate = DateTime.Now;
followUp.CreatedDate = DateTime.UtcNow;
await _context.FollowUps.AddAsync(followUp);
await _context.SaveChangesAsync();
return followUp;
@ -96,7 +96,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(FollowUps followUp)
{
followUp.LastModificationTime = DateTime.Now;
followUp.LastModificationTime = DateTime.UtcNow;
_context.FollowUps.Update(followUp);
await _context.SaveChangesAsync();
}
@ -218,7 +218,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<FollowUps> AddAsync(FollowUps followUp, CancellationToken cancellationToken)
{
followUp.CreatedDate = DateTime.Now;
followUp.CreatedDate = DateTime.UtcNow;
await _context.FollowUps.AddAsync(followUp, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return followUp;
@ -226,7 +226,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken)
{
followUp.LastModificationTime = DateTime.Now;
followUp.LastModificationTime = DateTime.UtcNow;
_context.FollowUps.Update(followUp);
await _context.SaveChangesAsync(cancellationToken);
}
@ -238,7 +238,7 @@ namespace SeaHaven.DataServices.Implementation
return false;
entity.Status = status;
entity.LastModificationTime = DateTime.Now;
entity.LastModificationTime = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
return true;
}

View file

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -15,25 +17,25 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations?> GetByIdAsync(int id)
{
return await _context.Locations.FindAsync(id);
return await _context.Locations.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
}
public async Task<Locations?> GetByIdWithDetailsAsync(int id)
{
return await _context.Locations
.FirstOrDefaultAsync(l => l.Id == id);
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
}
public async Task<IEnumerable<Locations>> GetAllAsync()
{
return await _context.Locations.ToListAsync();
return await _context.Locations.Where(l => l.IsDeleted != true).ToListAsync();
}
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
{
return await _context.Locations
.AsNoTracking()
.Where(l => l.AccountId == accountId)
.Where(l => l.AccountId == accountId && l.IsDeleted != true)
.ToListAsync();
}
@ -43,7 +45,7 @@ namespace SeaHaven.DataServices.Implementation
{
var row = await _context.Locations
.AsNoTracking()
.Where(l => l.Id == locationId)
.Where(l => l.Id == locationId && l.IsDeleted != true)
.Select(l => new { l.AccountId })
.FirstOrDefaultAsync(cancellationToken);
@ -55,7 +57,7 @@ namespace SeaHaven.DataServices.Implementation
int pageSize,
string? search = null)
{
var query = _context.Locations.AsQueryable();
var query = _context.Locations.Where(l => l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{
@ -80,7 +82,7 @@ namespace SeaHaven.DataServices.Implementation
{
var query = _context.Locations
.AsNoTracking()
.Where(l => l.AccountId != null);
.Where(l => l.AccountId != null && l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{
@ -98,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations> AddAsync(Locations location)
{
location.CreatedDate = DateTime.Now;
location.CreatedDate = DateTime.UtcNow;
await _context.Locations.AddAsync(location);
await _context.SaveChangesAsync();
return location;
@ -106,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Locations location)
{
location.LastModificationTime = DateTime.Now;
location.LastModificationTime = DateTime.UtcNow;
_context.Locations.Update(location);
await _context.SaveChangesAsync();
}
@ -123,12 +125,12 @@ namespace SeaHaven.DataServices.Implementation
public async Task<bool> ExistsAsync(int id)
{
return await _context.Locations.AnyAsync(l => l.Id == id);
return await _context.Locations.AnyAsync(l => l.Id == id && l.IsDeleted != true);
}
public async Task<int> CountAsync()
{
return await _context.Locations.CountAsync();
return await _context.Locations.CountAsync(l => l.IsDeleted != true);
}
public async Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
@ -142,7 +144,8 @@ namespace SeaHaven.DataServices.Implementation
{
IQueryable<Locations> query = _context.Locations
.AsNoTracking()
.Include(l => l.Account);
.Include(l => l.Account)
.Where(l => l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{
@ -200,15 +203,16 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Include(l => l.Account)
.Include(l => l.Contacts.Where(c => c.IsDeleted != true))
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
}
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
}
public async Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
@ -228,7 +232,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
{
location.CreatedDate = DateTime.Now;
location.CreatedDate = DateTime.UtcNow;
await _context.Locations.AddAsync(location, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return location;
@ -236,36 +240,72 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Locations location, CancellationToken cancellationToken)
{
location.LastModificationTime = DateTime.Now;
location.LastModificationTime = DateTime.UtcNow;
_context.Locations.Update(location);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
public async Task<bool> SiteCodeExistsAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken)
{
var entity = await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
if (entity == null)
return false;
var normalized = siteCode.Trim().ToUpperInvariant();
var now = DateTime.Now;
foreach (var contact in entity.Contacts ?? Enumerable.Empty<Contacts>())
{
if (contact.IsDeleted != true)
{
contact.IsDeleted = true;
contact.DeletionTime = now;
}
contact.LocationId = null;
}
_context.Locations.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
return true;
return await _context.Locations
.AsNoTracking()
.Where(l => l.IsDeleted != true
&& l.AccountId == accountId
&& l.Name != null
&& l.Name.Trim().ToUpper() == normalized)
.Where(l => excludeLocationId == null || l.Id != excludeLocationId)
.AnyAsync(cancellationToken);
}
public async Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
int locationId,
string? siteCode,
int? siteAccountId,
int? callerAccountId,
int maxIds,
CancellationToken cancellationToken)
{
var code = string.IsNullOrWhiteSpace(siteCode) ? null : siteCode.Trim().ToUpper();
var query = _context.workOrders
.AsNoTracking()
.Where(w => w.IsDeleted != true
&& (w.LocationId == locationId
|| (code != null
&& w.LocationId == null
&& w.SiteCode != null
&& w.SiteCode.Trim().ToUpper() == code
&& (w.AccountId == null || w.AccountId == siteAccountId))));
if (callerAccountId is int accountId)
query = query.Where(w => w.AccountId == accountId);
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
var count = await query.CountAsync(cancellationToken);
if (count == 0)
return (0, Array.Empty<int>());
var ids = await query
.OrderBy(w => w.Id)
.Select(w => w.Id)
.Take(maxIds)
.ToListAsync(cancellationToken);
return (count, ids);
}
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
.GetValues<LifecycleStatus>()
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
.ToList();
public async Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(
int page,
int pageSize,
@ -273,7 +313,7 @@ namespace SeaHaven.DataServices.Implementation
{
search ??= "";
var query = _context.Locations.AsQueryable();
var query = _context.Locations.Where(l => l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{

View file

@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Implementation
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" };
private const string AssignToAuditField = "AssignTo";
private const string LegacyAssignedToAuditField = "AssignedTo";
@ -176,6 +178,89 @@ namespace SeaHaven.DataServices.Implementation
return await candidates.Take(limit).ToListAsync(cancellationToken);
}
public async Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
NotificationFeedScope scope,
NotificationSlaCutoffs atRisk,
NotificationSlaCutoffs breached,
int breachedLimit,
CancellationToken cancellationToken)
{
var eligible = SlaEligibleWorkOrders(scope);
// Bounded by the longest window: nothing created more than 72 hours ago is still at risk.
var atRiskItems = await ProjectSla(CreatedAfter(CreatedAtOrBefore(eligible, atRisk), breached))
.OrderBy(candidate => candidate.CreatedAt)
.ThenBy(candidate => candidate.Id)
.ToListAsync(cancellationToken);
// Breaches stay until acknowledged, so legacy open work orders can make this set large.
var unacknowledged = CreatedAtOrBefore(eligible, breached)
.Where(w => !_context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
&& log.NewValue == w.Severity));
var breachedTotal = await unacknowledged.CountAsync(cancellationToken);
var breachedItems = breachedTotal == 0
? new List<NotificationSlaCandidate>()
: await ProjectSla(unacknowledged)
.OrderByDescending(candidate => candidate.CreatedAt)
.ThenByDescending(candidate => candidate.Id)
.Take(breachedLimit)
.ToListAsync(cancellationToken);
return new NotificationSlaCandidates
{
AtRisk = atRiskItems,
Breached = breachedItems,
BreachedTotal = breachedTotal
};
}
public Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken)
=> ProjectSla(SlaEligibleWorkOrders(scope).Where(w => w.Id == workOrderId))
.FirstOrDefaultAsync(cancellationToken);
// Same rule as the board's severity facet: Reactive or Emergency with a stored SEV level "1"–"5".
private IQueryable<WorkOrder> SlaEligibleWorkOrders(NotificationFeedScope scope)
=> OpenWorkOrders(scope).Where(w =>
(w.WorkOrderType == WorkOrderType.Reactive || w.WorkOrderType == WorkOrderType.Emergency)
&& SlaSeverities.Contains(w.Severity ?? "")
&& w.CreatedDate != null);
private static IQueryable<WorkOrder> CreatedAtOrBefore(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
{
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
return workOrders.Where(w =>
(w.Severity == "1" && w.CreatedDate <= sev1)
|| (w.Severity == "2" && w.CreatedDate <= sev2)
|| (w.Severity == "3" && w.CreatedDate <= sev3)
|| (w.Severity == "4" && w.CreatedDate <= sev4)
|| (w.Severity == "5" && w.CreatedDate <= sev5));
}
private static IQueryable<WorkOrder> CreatedAfter(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
{
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
return workOrders.Where(w =>
(w.Severity == "1" && w.CreatedDate > sev1)
|| (w.Severity == "2" && w.CreatedDate > sev2)
|| (w.Severity == "3" && w.CreatedDate > sev3)
|| (w.Severity == "4" && w.CreatedDate > sev4)
|| (w.Severity == "5" && w.CreatedDate > sev5));
}
private IQueryable<NotificationSlaCandidate> ProjectSla(IQueryable<WorkOrder> workOrders)
=> workOrders.Select(w => new NotificationSlaCandidate
{
Id = w.Id,
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
Severity = w.Severity!,
CreatedAt = w.CreatedDate!.Value,
BreachAcknowledged = _context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
&& log.NewValue == w.Severity)
});
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());

View file

@ -46,8 +46,8 @@ namespace SeaHaven.DataServices.Implementation
}
public async Task<(IEnumerable<PMSchedules> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
int page,
int pageSize,
string? search = null)
{
var query = _context.PMSchedules.AsQueryable();
@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<PMSchedules> AddAsync(PMSchedules schedule)
{
schedule.CreatedDate = DateTime.Now;
schedule.CreatedDate = DateTime.UtcNow;
await _context.PMSchedules.AddAsync(schedule);
await _context.SaveChangesAsync();
return schedule;
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(PMSchedules schedule)
{
schedule.LastModificationTime = DateTime.Now;
schedule.LastModificationTime = DateTime.UtcNow;
_context.PMSchedules.Update(schedule);
await _context.SaveChangesAsync();
}

View file

@ -0,0 +1,144 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class TeamMemberInviteDataService : ITeamMemberInviteDataService
{
private readonly ApplicationDbContext _context;
public TeamMemberInviteDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
_context.TeamMemberInvites.Add(invite);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task ReplaceOpenForUserAsync(
TeamMemberInvite invite,
DateTime now,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
await _context.TeamMemberInvites
.Where(existing => existing.UserId == invite.UserId
&& existing.UsedAt == null
&& existing.RevokedAt == null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(existing => existing.RevokedAt, now),
cancellationToken);
_context.TeamMemberInvites.Add(invite);
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
{
return _context.TeamMemberInvites
.AsNoTracking()
.Where(invite => invite.TokenHash == tokenHash)
.Select(invite => new TeamMemberInviteData
{
Id = invite.Id,
UserId = invite.UserId,
ExpiresAt = invite.ExpiresAt,
UsedAt = invite.UsedAt,
RevokedAt = invite.RevokedAt,
CodeHash = invite.CodeHash,
CodeSalt = invite.CodeSalt,
CodeExpiresAt = invite.CodeExpiresAt,
CodeFailedAttempts = invite.CodeFailedAttempts,
CodeSentAt = invite.CodeSentAt,
CodeSendCount = invite.CodeSendCount,
EmailConfirmedAt = invite.EmailConfirmedAt
})
.SingleOrDefaultAsync(cancellationToken);
}
public async Task<bool> TryStartCodeAsync(
StartTeamMemberInviteCodeCommand command,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
var updated = await OpenInvite(command.InviteId, command.Now)
.Where(invite => invite.CodeSendCount < command.MaxSends
&& (invite.CodeSentAt == null || invite.CodeSentAt <= command.LastSentNoLaterThan))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(invite => invite.CodeHash, command.CodeHash)
.SetProperty(invite => invite.CodeSalt, command.CodeSalt)
.SetProperty(invite => invite.CodeExpiresAt, command.CodeExpiresAt)
.SetProperty(invite => invite.CodeFailedAttempts, 0)
.SetProperty(invite => invite.CodeSentAt, command.Now)
.SetProperty(invite => invite.CodeSendCount, invite => invite.CodeSendCount + 1),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryReserveCodeAttemptAsync(
int inviteId,
int maxAttempts,
DateTime now,
CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.CodeHash != null
&& invite.CodeExpiresAt > now
&& invite.CodeFailedAttempts < maxAttempts)
.ExecuteUpdateAsync(
setters => setters.SetProperty(
invite => invite.CodeFailedAttempts,
invite => invite.CodeFailedAttempts + 1),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryConfirmEmailAsync(
int inviteId,
string codeHash,
DateTime now,
CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.CodeHash == codeHash && invite.CodeExpiresAt > now)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(invite => invite.EmailConfirmedAt, now)
.SetProperty(invite => invite.CodeHash, (string?)null)
.SetProperty(invite => invite.CodeSalt, (string?)null)
.SetProperty(invite => invite.CodeExpiresAt, (DateTime?)null)
.SetProperty(invite => invite.CodeFailedAttempts, 0),
cancellationToken);
return updated == 1;
}
public async Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken)
{
var updated = await OpenInvite(inviteId, now)
.Where(invite => invite.EmailConfirmedAt != null)
.ExecuteUpdateAsync(
setters => setters.SetProperty(invite => invite.UsedAt, now),
cancellationToken);
return updated == 1;
}
private IQueryable<TeamMemberInvite> OpenInvite(int inviteId, DateTime now)
{
return _context.TeamMemberInvites.Where(invite => invite.Id == inviteId
&& invite.UsedAt == null
&& invite.RevokedAt == null
&& invite.ExpiresAt > now);
}
}
}

View file

@ -11,6 +11,9 @@ namespace SeaHaven.DataServices.Implementation
public class UpliftDataService : IUpliftDataService
{
private static readonly ConcurrentDictionary<int, SemaphoreSlim> WorkOrderGates = new();
// The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected.
private static readonly string[] RejectedStatuses = { "Rejected", "Denied" };
private readonly ApplicationDbContext _context;
public UpliftDataService(ApplicationDbContext context)
@ -46,7 +49,9 @@ namespace SeaHaven.DataServices.Implementation
&& (d.IsDeleted == null || d.IsDeleted == false)
select new { u, d, v, ev, effectiveWorkOrderId, workOrder, reqUser, decUser };
if (!string.IsNullOrWhiteSpace(status))
if (string.Equals(status, "Rejected", StringComparison.Ordinal))
query = query.Where(x => RejectedStatuses.Contains(x.u.Status));
else if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.u.Status == status);
if (tier.HasValue)
query = query.Where(x => x.u.RequiredTier == tier.Value);
@ -54,12 +59,13 @@ namespace SeaHaven.DataServices.Implementation
var total = await query.CountAsync(cancellationToken);
// Approval queue read contract: the actionable queue (Pending) surfaces the
// oldest request first; the decision log (Approved) surfaces the most
// recently decided first. Every other read keeps the historical
// oldest request first; the decision logs (Approved, Rejected) surface the
// most recently decided first. Every other read keeps the historical
// newest-request-first order. Id is the deterministic tiebreaker.
if (string.Equals(status, "Pending", StringComparison.Ordinal))
query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id);
else if (string.Equals(status, "Approved", StringComparison.Ordinal))
else if (string.Equals(status, "Approved", StringComparison.Ordinal)
|| string.Equals(status, "Rejected", StringComparison.Ordinal))
query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id);
else
query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);
@ -243,6 +249,9 @@ namespace SeaHaven.DataServices.Implementation
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
// Vendor sessions have no identity user, so createdby is null
// only on requests the vendor raised in the portal.
RaisedByVendor = u.createdby == null,
CreatedDate = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,

View file

@ -159,6 +159,10 @@ namespace SeaHaven.DataServices.Implementation
.Where(permissionOverride => permissionOverride.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.TeamMemberInvites
.Where(invite => invite.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.Users
.Where(existingUser => existingUser.Id == id)
.ExecuteDeleteAsync(cancellationToken);

View file

@ -17,6 +17,39 @@ namespace SeaHaven.DataServices.Implementation
// supporting evidence never participates in completion versioning or replacement.
private const string CompletionPurpose = "Completion";
public async Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId,
int? excludingDocumentId,
CancellationToken cancellationToken)
{
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
// a newer completion version supersedes a document.
return await _context.VendorCompletionDocuments
.AsNoTracking()
.Where(document => document.WorkOrderId == workOrderId
&& (document.IsDeleted == null || document.IsDeleted == false)
&& (excludingDocumentId == null || document.Id != excludingDocumentId)
&& !_context.VendorCompletionDocuments.Any(newer =>
newer.ReplacesDocumentId == document.Id
&& newer.Purpose == CompletionPurpose
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
.Select(document => document.ContentType)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(attachment => attachment.WorkorderId == workOrderId
&& attachment.IsDeleted != true
&& attachment.Attachments != null)
.Select(attachment => attachment.Attachments!)
.ToListAsync(cancellationToken);
}
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{
return _context.VendorCompletionDocuments

View file

@ -150,7 +150,7 @@ namespace SeaHaven.DataServices.Implementation
}
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
=> _context.Locations.AnyAsync(location => location.Id == locationId && location.IsDeleted != true, cancellationToken);
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))

View file

@ -19,10 +19,31 @@ namespace SeaHaven.DataServices.Implementation
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (query.AccountId.HasValue)
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
// An exact id set (a link from an alert or a linked-work-orders list) shows those work
// orders whatever their date, status or owner; only the base and tenant scope still apply.
baseQuery = query.Ids is { Count: > 0 } ids
? baseQuery.Where(w => ids.Contains(w.Id))
: ApplyFilters(baseQuery, query);
var totalCount = await baseQuery.CountAsync();
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
}
private IQueryable<WorkOrder> ApplyFilters(IQueryable<WorkOrder> baseQuery, WorkOrderAdvancedSearchQuery query)
{
if (query.UnscheduledOnly)
baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery);
else
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
baseQuery, query.DateFrom, query.DateTo, query.IncludeDateless);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
@ -46,15 +67,7 @@ namespace SeaHaven.DataServices.Implementation
if (normalizedSearch != null)
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
var totalCount = await baseQuery.CountAsync();
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
return baseQuery;
}
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)

View file

@ -41,9 +41,6 @@ namespace SeaHaven.DataServices.Implementation
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}

View file

@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<WorkOrder> AddAsync(WorkOrder workOrder)
{
workOrder.CreatedDate = DateTime.Now;
workOrder.CreatedDate ??= DateTime.UtcNow;
await _context.workOrders.AddAsync(workOrder);
await _context.SaveChangesAsync();
return workOrder;
@ -201,7 +201,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(WorkOrder workOrder)
{
workOrder.LastModificationTime = DateTime.Now;
workOrder.LastModificationTime = DateTime.UtcNow;
_context.workOrders.Update(workOrder);
await _context.SaveChangesAsync();
}

View file

@ -56,6 +56,36 @@ namespace SeaHaven.DataServices.Implementation
public void TrackAttachment(WorkOrderAttachments attachment)
=> _context.workOrderAttachments.Add(attachment);
public async Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken)
{
var urls = await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true && a.Attachments != null)
.Select(a => a.Attachments!)
.ToListAsync(cancellationToken);
return urls;
}
public async Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId,
CancellationToken cancellationToken)
{
// Uplift evidence also records the latest completion id in ReplacesDocumentId, so only
// a newer completion version supersedes a document.
return await _context.VendorCompletionDocuments
.AsNoTracking()
.Where(document => document.WorkOrderId == workOrderId
&& (document.IsDeleted == null || document.IsDeleted == false)
&& !_context.VendorCompletionDocuments.Any(newer =>
newer.ReplacesDocumentId == document.Id
&& newer.Purpose == "Completion"
&& (newer.IsDeleted == null || newer.IsDeleted == false)))
.Select(document => document.ContentType)
.ToListAsync(cancellationToken);
}
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;

View file

@ -0,0 +1,26 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record CompletionDocTemplateProcedureRow(
int Id,
int SortOrder,
string Name,
string Description);
// Read model for the completion document templates registry: the legacy
// columns plus authored content and audit display data.
public record CompletionDocTemplateDetailRow(
int Id,
string Name,
string ServiceKey,
WorkOrderType? WorkOrderType,
string TemplateUrl,
bool IsActive,
string? ExtraSafetyNote,
DateTime? CreatedAt,
DateTime? UpdatedAt,
string? CreatedByFirstName,
string? CreatedByLastName,
IReadOnlyList<CompletionDocTemplateProcedureRow> Procedures);
}

View file

@ -14,6 +14,18 @@ namespace SeaHaven.DataServices.Interfaces
DateOnly today,
CancellationToken cancellationToken);
/// <summary>
/// Open work orders with no dispatcher in the period, counted with the
/// same filters as GET /board/search for Dispatcher=Unassigned,
/// so the Dashboard number matches the list it drills into.
/// </summary>
Task<int> CountOpenUnassignedAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken);
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,

View file

@ -52,6 +52,31 @@ namespace SeaHaven.DataServices.Interfaces
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken);
/// <summary>
/// True when a live (not deleted) site of the same account already uses
/// <paramref name="siteCode"/>, compared trimmed and case-insensitively.
/// A null account matches only other sites without an account.
/// </summary>
Task<bool> SiteCodeExistsAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken);
/// <summary>
/// Open (not Completed or Canceled, legacy status aware) and not deleted work
/// orders that reference the site by id, or by site code when they carry no
/// location id and no other account. <paramref name="callerAccountId"/>
/// narrows to one account. Returns the full count and at most
/// <paramref name="maxIds"/> ids, ascending.
/// </summary>
Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
int locationId,
string? siteCode,
int? siteAccountId,
int? callerAccountId,
int maxIds,
CancellationToken cancellationToken);
}
}

View file

@ -48,5 +48,22 @@ namespace SeaHaven.DataServices.Interfaces
/// </summary>
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
/// <summary>
/// Open Reactive/Emergency work orders with a SEV 1–5 level, split by creation time: at risk when
/// created at or before <paramref name="atRisk"/> but after <paramref name="breached"/>, breached when
/// created at or before <paramref name="breached"/> and not yet acknowledged for their current level.
/// Breaches are the <paramref name="breachedLimit"/> newest; the at-risk set is bounded by the longest window.
/// </summary>
Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
NotificationFeedScope scope,
NotificationSlaCutoffs atRisk,
NotificationSlaCutoffs breached,
int breachedLimit,
CancellationToken cancellationToken);
/// <summary>The work order as an SLA candidate inside <paramref name="scope"/>, or null when it is not one.</summary>
Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,30 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Dto;
namespace SeaHaven.DataServices.Interfaces
{
/// <summary>
/// Persistence for team member registration invites. Every state transition that
/// guards a security limit is a single conditional update, so concurrent requests
/// cannot exceed the limit; each returns whether the transition happened.
/// </summary>
public interface ITeamMemberInviteDataService
{
Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken);
/// <summary>Revokes every open invite for the invite's user and adds the new one, atomically.</summary>
Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken);
Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken);
Task<bool> TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken);
/// <summary>Counts one verification attempt against a live code; false once the attempt limit is used up.</summary>
Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken);
Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken);
/// <summary>Marks an open, unexpired, email-confirmed invite as used; false when another request already did.</summary>
Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken);
}
}

View file

@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor documents (not deleted, not replaced by a
/// newer completion version), optionally excluding one being replaced. Feeds the per-work-order photo/video counts.
/// </summary>
Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
int workOrderId,
int? excludingDocumentId,
CancellationToken cancellationToken);
/// <summary>Stored URLs of the work order's non-deleted dispatcher attachments (photo/video counts).</summary>
Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View file

@ -15,7 +15,6 @@ namespace SeaHaven.DataServices.Interfaces
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -25,11 +25,43 @@ namespace SeaHaven.DataServices.Interfaces
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken);
/// <summary>Tracked, not-deleted template with its procedures loaded, for editing.</summary>
Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken);
Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken);
/// <summary>
/// Commits edits to a template from <see cref="GetTrackedForEditAsync"/>. A non-null
/// <paramref name="replacementProcedures"/> replaces the whole procedure list.
/// </summary>
Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken);
/// <summary>
/// Soft-deletes the template and clears every Service link to it in the same commit.
/// Services keep RequiresCompletionDocument. False when the template does not exist.
/// </summary>
Task<bool> DeleteAndUnlinkServicesAsync(int id, string? deleterUserId, CancellationToken cancellationToken);
/// <summary>
/// Ids of open (non-terminal) work orders whose Service links this template,
/// restricted to <paramref name="accountId"/> when set.
/// </summary>
Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken);
}
}

View file

@ -22,6 +22,19 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment);
/// <summary>Stored URLs of the work order's non-deleted attachments (photo/video counts).</summary>
Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
int workOrderId,
CancellationToken cancellationToken);
/// <summary>
/// Content types of the work order's current vendor-portal documents (not deleted, not
/// replaced by a newer completion version). The counts span both upload surfaces.
/// </summary>
Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
int workOrderId,
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
void MarkWorkOrderModified(WorkOrder workOrder);
Task SaveAsync(CancellationToken cancellationToken);

View file

@ -32,7 +32,9 @@ namespace SeaHaven.DataServices.Interfaces
int PageSize,
string SortBy,
string SortDir,
int? AccountId = null);
int? AccountId = null,
bool IncludeDateless = false,
IReadOnlyList<int>? Ids = null);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,

View file

@ -64,3 +64,38 @@ public sealed class NotificationUpliftDecisionCandidate
public string? DecisionNote { get; init; }
public DateTime DecidedAt { get; init; }
}
/// <summary>
/// Per SEV level, the latest work-order creation time that has reached a response-window threshold.
/// The business service computes them; the query only compares creation times.
/// </summary>
public sealed record NotificationSlaCutoffs(DateTime Sev1, DateTime Sev2, DateTime Sev3, DateTime Sev4, DateTime Sev5);
/// <summary>
/// The work-order audit entry that records an SLA breach acknowledgement: who (UserId), when (CreatedAt)
/// and the SEV level whose deadline was missed (NewValue). It is also what keeps the breach out of the feed.
/// </summary>
public static class SlaBreachAcknowledgementAudit
{
public const string FieldName = "SlaBreachAcknowledged";
}
/// <summary>An open Reactive/Emergency work order with a SEV 1–5 level and a creation time.</summary>
public sealed class NotificationSlaCandidate
{
public int Id { get; init; }
public string? Number { get; init; }
public string Severity { get; init; } = "";
public DateTime CreatedAt { get; init; }
/// <summary>A breach of the work order's current severity has already been acknowledged.</summary>
public bool BreachAcknowledged { get; init; }
}
public sealed class NotificationSlaCandidates
{
/// <summary>Every work order past half its window but not past the whole window.</summary>
public IReadOnlyList<NotificationSlaCandidate> AtRisk { get; init; } = Array.Empty<NotificationSlaCandidate>();
/// <summary>Unacknowledged breaches: the newest slice, plus the unbounded total.</summary>
public IReadOnlyList<NotificationSlaCandidate> Breached { get; init; } = Array.Empty<NotificationSlaCandidate>();
public int BreachedTotal { get; init; }
}

View file

@ -0,0 +1,174 @@
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using System.Security.Claims;
using Xunit;
namespace SeaHaven.Services.Tests;
public sealed class TeamEffectivePermissionsTests
{
[Fact]
public async Task Scheduler_Receives_Role_Defaults()
{
var data = new FakeUsers().Add("u1", "Scheduler");
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value!.Permissions.Should().Contain(new[]
{
TeamPermissionKeys.CreateCompletionDocTemplates,
TeamPermissionKeys.EditCompletionDocTemplates
});
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.DeleteCompletionDocTemplates);
}
[Fact]
public async Task Grant_Override_Adds_A_Key_Outside_The_Role_Defaults()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher", (TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
}
[Fact]
public async Task Revoke_Override_Removes_A_Role_Default()
{
var data = new FakeUsers().Add(
"u1", "Scheduler", (TeamPermissionKeys.EditCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
result.Value.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
}
[Fact]
public async Task Admin_Holds_Every_Key_Even_With_A_Revoke_Override()
{
var data = new FakeUsers().Add(
"u1", "Admin", (TeamPermissionKeys.DeleteCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(TeamPermissionKeys.All);
}
[Fact]
public async Task Keys_Match_What_The_Write_Path_Enforces()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher",
(TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow),
(TeamPermissionKeys.CreateSites, UserPermissionState.Deny));
var policy = new TeamPermissionPolicy();
var user = await data.GetUserAsync("u1", CancellationToken.None);
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(
TeamPermissionKeys.All.Where(key => policy.IsAllowed(user!.RoleName, key, user.Overrides)));
}
[Fact]
public async Task Reads_Only_The_Caller_Identified_By_The_Token()
{
var data = new FakeUsers()
.Add("u1", "Dispatcher")
.Add("u2", "Dispatcher", (TeamPermissionKeys.DeleteWorkOrders, UserPermissionState.Allow));
using var cancellation = new CancellationTokenSource();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), cancellation.Token);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.DeleteWorkOrders);
data.RequestedUserIds.Should().Equal("u1");
data.LastToken.Should().Be(cancellation.Token);
}
[Fact]
public async Task Unauthenticated_Caller_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var anonymous = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }));
var result = await Service(data).GetEffectivePermissionsAsync(anonymous, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_Without_A_User_Id_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var noId = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
var result = await Service(data).GetEffectivePermissionsAsync(noId, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_For_A_Removed_User_Is_Rejected()
{
var data = new FakeUsers();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("gone"), CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
result.Value.Should().BeNull();
}
private static TeamPermissionService Service(FakeUsers data) =>
new(data, new TeamPermissionPolicy());
private static ClaimsPrincipal Caller(string userId) =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"));
private sealed class FakeUsers : ITeamPermissionOverrideDataService
{
private readonly Dictionary<string, TeamPermissionUserData> _users = new();
public List<string> RequestedUserIds { get; } = new();
public CancellationToken LastToken { get; private set; }
public FakeUsers Add(string userId, string role, params (string Key, UserPermissionState State)[] overrides)
{
_users[userId] = new TeamPermissionUserData
{
UserId = userId,
RoleName = role,
Overrides = overrides.ToDictionary(o => o.Key, o => o.State, StringComparer.OrdinalIgnoreCase)
};
return this;
}
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
{
RequestedUserIds.Add(userId);
LastToken = cancellationToken;
return Task.FromResult(_users.GetValueOrDefault(userId));
}
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task SetOverridesAsync(string userId, IReadOnlyDictionary<string, UserPermissionState> overrides, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
}
}

View file

@ -106,6 +106,8 @@ namespace SeaHaven.Services.DTOs
public int ScheduledTomorrow { get; set; }
public int PendingUplifts { get; set; }
public int AvetaPending { get; set; }
/// <summary>Open work orders with no dispatcher in the selected period.</summary>
public int Unassigned { get; set; }
public DashboardBreakdownDTO Breakdown { get; set; } = new();
public int DueCount { get; set; }
public int CompletedDueCount { get; set; }

View file

@ -11,6 +11,20 @@ namespace SeaHaven.Services.DTOs
public string Id { get; set; } = string.Empty;
}
public enum ChangePasswordStatus
{
Succeeded,
CurrentPasswordIncorrect,
PasswordRejected,
/// <summary>Identity failed for a reason other than the password policy.</summary>
Failed
}
public sealed class ChangePasswordResultDTO
{
public ChangePasswordStatus Status { get; init; }
}
public class UpdateProfileRequestDTO
{
public string? Name { get; set; }

View file

@ -15,6 +15,7 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int? AccountId { get; set; }
public string? AccountName { get; set; }
public string? Notes { get; set; }
public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; }
@ -70,6 +71,7 @@ namespace SeaHaven.Services.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
public string? Notes { get; set; }
/// <summary>SH-138: null keeps legacy behavior; empty array is a validation error.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
@ -88,6 +90,9 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int? AccountId { get; set; }
/// <summary>Null keeps the stored notes.</summary>
public string? Notes { get; set; }
/// <summary>SH-138: null keeps legacy behavior and must not mutate contact rows.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
}
@ -99,4 +104,18 @@ namespace SeaHaven.Services.DTOs
public string? City { get; set; }
public string? State { get; set; }
}
/// <summary>Site contacts and notes edited from the work-order Site dialog.</summary>
public class SiteContactInfoRequestDTO
{
public List<SiteContactRequestDTO>? Contacts { get; set; }
public string? Notes { get; set; }
}
/// <summary>Open (not Completed or Canceled) work orders that reference a site.</summary>
public class SiteOpenWorkOrdersDTO
{
public int Count { get; set; }
public IReadOnlyList<int> WorkOrderIds { get; set; } = Array.Empty<int>();
}
}

Some files were not shown because too many files have changed in this diff Show more