Compare commits

...

45 commits

Author SHA1 Message Date
Arthur Bassi
49406fa32d fix(work-orders): align Overdue type rejection test with dueDate message 2026-08-04 14:18:05 -03:00
Arthur Bassi
07aafbc04c fix(work-orders): derive Past Due from DueDate instead of ScheduledDate
Past Due must track the deadline (Due Date), not Schedule On. Keep dueDate and scheduledDate PATCH mutations independent so rescheduling alone does not clear Past Due.
2026-08-04 14:18:05 -03:00
dependabot[bot]
a7b1f3bc40
chore(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /infra/cdk (#53)
Some checks failed
Validate and deploy dev / Validate deployable source bundle (push) Has been cancelled
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Has been cancelled
* chore(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /infra/cdk

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(cdk): migrate TypeScript config for TS 7 moduleResolution

Use Node16 module/moduleResolution and explicit .js relative import
so npm run synth succeeds under TypeScript 7.0.2.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Arthur Bassi <arthur.bassi@luby.com.br>
2026-08-04 10:36:09 -03:00
dependabot[bot]
7a72be3088
chore(deps): bump constructs from 10.7.1 to 10.7.2 in /infra/cdk (#55)
Bumps [constructs](https://github.com/aws/constructs) from 10.7.1 to 10.7.2.
- [Release notes](https://github.com/aws/constructs/releases)
- [Commits](https://github.com/aws/constructs/compare/v10.7.1...v10.7.2)

---
updated-dependencies:
- dependency-name: constructs
  dependency-version: 10.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-04 10:17:22 -03:00
dependabot[bot]
466a40d8de
chore(deps-dev): bump @types/node from 22.20.1 to 26.1.2 in /infra/cdk (#54)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.20.1 to 26.1.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 10:10:29 -03:00
dependabot[bot]
b27bf06c8e
chore(deps-dev): bump aws-cdk from 2.1133.0 to 2.1134.0 in /infra/cdk (#52)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1133.0 to 2.1134.0.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1134.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1134.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-04 10:03:06 -03:00
dependabot[bot]
66e1a662c3
chore(deps): bump actions/checkout from 4 to 7 (#51)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-04 09:56:24 -03:00
dependabot[bot]
c7e7ffa313
chore(deps): bump actions/setup-dotnet from 4 to 6 (#50)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-04 09:49:05 -03:00
dependabot[bot]
b2366acdfa
chore(deps): bump actions/setup-node from 6.5.0 to 7.0.0 (#49)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.5.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](249970729c...8207627860)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 09:40:55 -03:00
Alexandre Brandizzi
669e9b2932
feat(vendors): add company roster management (SH-198) (#48)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
* feat(vendors): add company roster management

* fix(security): remove request-controlled write guards

* fix(vendors): synchronize roster company fields

* fix(vendors): source facets from companies
2026-08-03 17:53:24 -03:00
Alexandre Brandizzi
47c3fff4ba
fix(cdk): allow Beanstalk VPC discovery (#44)
Some checks failed
Validate and deploy dev / Validate deployable source bundle (push) Has been cancelled
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Has been cancelled
* fix(cdk): allow Beanstalk VPC discovery

* chore(ci): clarify backend check name

* fix(eb): allow temporary object cleanup

* fix(cdk): allow managed environment stack update

* fix(cdk): allow Beanstalk template read

* fix(cdk): apply supported Beanstalk S3 policy

* fix(cdk): allow load balancer discovery and cancel

* fix(cdk): allow Beanstalk resource discovery
2026-07-30 11:40:06 -04:00
Adam Moussa
83ed6a1790
fix(eb): configure work-order webhook HMAC secret source (#41)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
2026-07-30 11:44:43 -03:00
Alexandre Brandizzi
85ce7c6384
Merge pull request #40 from Sea-Haven-Industries/codex/vendor-document-detail-read-model
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
feat(vendor-portal): complete dispatch detail read model
2026-07-30 11:06:09 -03:00
Alexandre Brandizzi
1a169360a3
Merge branch 'dev' into codex/vendor-document-detail-read-model 2026-07-29 22:06:48 -03:00
Alexandre Brandizzi
1a3d9ac3e0
Merge pull request #43 from Sea-Haven-Industries/fix/eb-runtime-copy-permission
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
fix(cdk): allow Beanstalk runtime bundle copy
2026-07-29 21:59:04 -03:00
brandizzi
76cdc70c18 fix(cdk): allow EB runtime manifest updates 2026-07-29 11:07:53 -03:00
brandizzi
b8db4a7e61 fix(cdk): allow EB bucket policy read 2026-07-29 11:02:30 -03:00
brandizzi
c2f2c411d8 fix(cdk): allow EB extension verification 2026-07-29 10:33:31 -03:00
brandizzi
5b719a660e fix(cdk): allow EB environment extension write 2026-07-29 09:55:58 -03:00
brandizzi
3f60730464 fix(cdk): allow EB embedded extension write 2026-07-29 09:45:45 -03:00
brandizzi
156b7bbed6 fix(cdk): allow EB runtime version ACL write 2026-07-29 09:30:48 -03:00
brandizzi
9f54399e63 fix(cdk): allow EB runtime version ACL read 2026-07-29 09:26:12 -03:00
brandizzi
424bf20f54 fix(cdk): allow EB runtime verification 2026-07-29 09:21:28 -03:00
brandizzi
42d8772951 fix(cdk): allow EB runtime cleanup 2026-07-29 09:05:44 -03:00
brandizzi
b02787087e fix(cdk): allow EB runtime bundle copy 2026-07-29 08:56:19 -03:00
Alexandre Brandizzi
68162ef0fb
Merge pull request #42 from Sea-Haven-Industries/fix/eb-aws-owned-s3-scope
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
fix(cdk): grant AWS-confirmed Beanstalk ACL read
2026-07-29 08:46:44 -03:00
brandizzi
061a084fda fix(cdk): grant AWS-confirmed EB ACL read 2026-07-29 08:01:01 -03:00
Alexandre Brandizzi
148a0750f9 feat(vendor-portal): expose dispatch payment details 2026-07-28 17:19:42 -03:00
Alexandre Brandizzi
eb796ab591 Merge remote-tracking branch 'origin/dev' into codex/vendor-document-detail-read-model 2026-07-28 16:12:10 -03:00
Alexandre Brandizzi
658bae77d3
fix(cdk): grant observed Elastic Beanstalk deploy reads (#38)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
* fix(cdk): grant observed EB deploy reads

* fix(cdk): model EB deployment capability

* fix(cdk): scope EB platform ACL read

* fix(deploy): verify exact EB release

* docs(deploy): record unresolved EB ACL gate
2026-07-28 15:04:48 -03:00
Alexandre Brandizzi
36d0a638a2 fix(vendors): return completion documents in portal detail 2026-07-28 14:19:15 -03:00
Alexandre Brandizzi
5ecb377613
fix: align vendor document API with frontend (#37)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
2026-07-28 13:57:45 -03:00
Alexandre Brandizzi
45ffa68dfa
fix: allow Elastic Beanstalk bucket setup check (#36)
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
2026-07-28 12:51:20 -03:00
Alexandre Brandizzi
8f41190a74
Merge pull request #35 from Sea-Haven-Industries/codex/sh133-s3-read-fix
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
Fix Elastic Beanstalk bundle validation
2026-07-28 11:19:57 -03:00
Alexandre Brandizzi
34b5f09828 fix: allow deployment bundle validation 2026-07-28 11:12:39 -03:00
Alexandre Brandizzi
8539c07f82
Merge pull request #33 from Sea-Haven-Industries/codex/sh-133-cdk-deploy
SH-133: automate dev backend deployment
2026-07-28 10:59:28 -03:00
Alexandre Brandizzi
da29dcf983 fix: make deploy artifacts immutable 2026-07-28 10:38:38 -03:00
Alexandre Brandizzi
73b525a685 ci: document CDK advisory exception 2026-07-27 19:29:37 -03:00
Alexandre Brandizzi
9057668459 ci: automate dev backend deployment 2026-07-27 19:26:07 -03:00
Alexandre Brandizzi
4bfd8bab72
Merge pull request #32 from Sea-Haven-Industries/feature/sh-133-procurement-ingest
SH-133: Complete procurement work-order ingestion
2026-07-27 17:44:31 -03:00
Alexandre Brandizzi
f701899a83 fix(work-orders): satisfy producer contract review 2026-07-27 16:33:06 -03:00
Alexandre Brandizzi
27bf81b7f8 fix(work-orders): address procurement review findings 2026-07-27 14:40:17 -03:00
Alexandre Brandizzi
8a2e260177 test: prove SH-133 webhook and admin boundaries 2026-07-25 15:45:52 -03:00
Alexandre Brandizzi
e3c37e54b4 feat: complete SH-133 procurement reconciliation 2026-07-24 22:13:25 -03:00
Alexandre Brandizzi
bdffe77e42 feat: ingest signed procurement work-order webhooks 2026-07-24 21:03:50 -03:00
113 changed files with 21926 additions and 229 deletions

View file

@ -0,0 +1,16 @@
# Runs the EF Core migrations bundle on the leader instance only, before the new
# application version becomes active. The bundle is a self-contained linux-x64
# executable placed at the root of the deployment archive by
# scripts/package-elastic-beanstalk.sh.
#
# The connection string is provided at runtime by the existing Elastic Beanstalk
# environment property ConnectionStrings__DefaultConnection. It must NOT be
# passed on the command line (it would leak into the process table and logs); the
# bundle reads it through the application configuration environment provider.
#
# A non-zero exit fails the deployment (Elastic Beanstalk aborts on container
# command failure), so a migration error never yields a live broken deployment.
container_commands:
01_run_ef_migrations:
command: "chmod +x ./efbundle && ./efbundle"
leader_only: true

View file

@ -0,0 +1,10 @@
# Enables the dev receiver and sets the non-secret pointer it reads to locate
# the producer HMAC keyset. The keyset itself is fetched at runtime through the
# instance role's cross-account grant. Do not set these properties directly
# with update-environment: directly set values take precedence over
# .ebextensions and would silently turn this file into dead text.
option_settings:
aws:elasticbeanstalk:application:environment:
WorkOrderWebhook__Enabled: "true"
WorkOrderWebhook__Region: us-east-1
WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB

13
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: npm
directory: /infra/cdk
schedule:
interval: weekly
open-pull-requests-limit: 5

View file

@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: "8.0.x"

View file

@ -1,11 +1,34 @@
name: CI
name: Backend CI
on:
pull_request:
branches: [main, dev]
permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main
with:
dotnet-version: "8.0.x"
solution: "SeaHavenIndustries.sln"
run-tests: true
build-and-test:
name: Build and test
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: backend-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: "8.0.x"
- name: Restore
run: dotnet restore SeaHavenIndustries.sln
- name: Build
run: dotnet build SeaHavenIndustries.sln --no-restore --configuration Release
- name: Test
run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release

View file

@ -4,3 +4,5 @@ on:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main
with:
allow-ghsas: GHSA-mh99-v99m-4gvg

262
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,262 @@
name: Validate and deploy dev
on:
pull_request:
branches: [dev]
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
name: Validate deployable source bundle
runs-on: ubuntu-latest
steps:
- name: Checkout
# actions/checkout @ v7.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
- name: Set up .NET
# actions/setup-dotnet @ v6.0.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
with:
dotnet-version: "8.0.x"
- name: Set up Node.js
# actions/setup-node @ v6.5.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: "22.22.1"
cache: npm
cache-dependency-path: infra/cdk/package-lock.json
- name: Repository quality gate
run: bash scripts/governance-check.sh
- name: Validate CDK deployment infrastructure
run: |
npm ci --prefix infra/cdk
npm run synth --prefix infra/cdk
- name: Build Elastic Beanstalk source bundle
run: bash scripts/package-elastic-beanstalk.sh
- name: Inspect source bundle contract
run: |
set -euo pipefail
unzip -t .artifacts/elastic-beanstalk/site.zip
unzip -Z1 .artifacts/elastic-beanstalk/site.zip \
> .artifacts/elastic-beanstalk/zip-contents.txt
grep -Fxq "efbundle" .artifacts/elastic-beanstalk/zip-contents.txt
grep -Fxq ".ebextensions/01_migrations.config" \
.artifacts/elastic-beanstalk/zip-contents.txt
grep -Fxq ".ebextensions/02_webhook_config.config" \
.artifacts/elastic-beanstalk/zip-contents.txt
unzip -p .artifacts/elastic-beanstalk/site.zip \
.ebextensions/02_webhook_config.config \
> .artifacts/elastic-beanstalk/webhook-config.txt
grep -Fxq ' WorkOrderWebhook__Enabled: "true"' \
.artifacts/elastic-beanstalk/webhook-config.txt
grep -Fxq ' WorkOrderWebhook__Region: us-east-1' \
.artifacts/elastic-beanstalk/webhook-config.txt
grep -Fxq \
' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
.artifacts/elastic-beanstalk/webhook-config.txt
deploy:
name: Deploy shoc-backend to Elastic Beanstalk dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment:
name: dev
concurrency:
group: deploy-dev
cancel-in-progress: false
steps:
- name: Checkout
# actions/checkout @ v7.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up .NET
# actions/setup-dotnet @ v6.0.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
with:
dotnet-version: "8.0.x"
- name: Build Elastic Beanstalk source bundle
run: bash scripts/package-elastic-beanstalk.sh
- name: Configure AWS credentials (OIDC)
# aws-actions/configure-aws-credentials @ v6.2.3
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Capture current environment version
run: |
set -euo pipefail
prev="$(aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--region us-east-1 \
--query 'Environments[0].VersionLabel' \
--output text)"
echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt
echo "Previous version label: $prev"
- name: Deploy prebuilt bundle to existing environment
# aws-actions/aws-elasticbeanstalk-deploy @ v1.0.6
uses: aws-actions/aws-elasticbeanstalk-deploy@cfad3e5e4452cd9c8923cbee2f862e96ba4b52c4
with:
aws-region: us-east-1
application-name: shoc-backend
environment-name: shoc-backend-dev
version-label: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
deployment-package-path: .artifacts/elastic-beanstalk/site.zip
s3-bucket-name: elasticbeanstalk-us-east-1-396287094661
create-application-if-not-exists: "false"
create-environment-if-not-exists: "false"
create-s3-bucket-if-not-exists: "false"
use-existing-application-version-if-available: "false"
wait-for-deployment: "true"
wait-for-environment-recovery: "true"
- name: Verify exact application version is active
run: |
set -euo pipefail
expected="${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}"
status="Unknown"
current="Unknown"
health="Unknown"
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$expected" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
echo "Expected application version is Ready and healthy."
exit 0
fi
echo "Environment became Ready without activating expected version $expected." >&2
exit 1
fi
sleep 15
done
echo "Expected application version did not become Ready within the deployment window." >&2
exit 1
- name: Post-deploy smoke
run: bash scripts/smoke-elastic-beanstalk.sh https://api.dev.seahaven.com
- name: Verify webhook secret source is operational
run: |
set -euo pipefail
response_file="$(mktemp)"
trap 'rm -f "$response_file"' EXIT
status="$(curl --silent --show-error \
--output "$response_file" \
--write-out '%{http_code}' \
--request POST \
--header 'Content-Type: application/json' \
--header "X-SH-Timestamp: $(date +%s)" \
--header 'X-SH-Key-Id: deployment-smoke-invalid-key' \
--header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \
--data '{}' \
https://api.dev.seahaven.com/api/webhooks/work-orders)"
if [ "$status" != "401" ]; then
echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2
sed -n '1,20p' "$response_file" >&2
exit 1
fi
- name: Restore previous application version on failure (schema is not reverted)
if: failure()
run: |
set -euo pipefail
prev_file=".artifacts/elastic-beanstalk/previous-version.txt"
if [ ! -f "$prev_file" ]; then
echo "No previous version captured; nothing to roll back." >&2
exit 0
fi
prev="$(cat "$prev_file")"
if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then
echo "No previous version recorded; nothing to roll back." >&2
exit 0
fi
echo "Waiting for any in-flight environment update to settle..."
status="Unknown"
current="Unknown"
health="Unknown"
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
break
fi
sleep 15
done
if [ "$status" != "Ready" ]; then
echo "Environment did not settle before rollback." >&2
exit 1
fi
if [ "$current" = "$prev" ]; then
echo "Environment is already on previous version $prev."
exit 0
fi
echo "Restoring shoc-backend-dev application code to version label: $prev"
echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy."
aws elasticbeanstalk update-environment \
--environment-name shoc-backend-dev \
--version-label "$prev" \
--region us-east-1
echo "Waiting for previous version to become healthy..."
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
echo "Application version restore complete; previous code is Ready and healthy."
exit 0
fi
echo "Rollback reached Ready in an unexpected version/health state." >&2
exit 1
fi
sleep 15
done
echo "Environment did not return to Ready within rollback window." >&2
exit 1

9
.gitignore vendored
View file

@ -365,3 +365,12 @@ FodyWeavers.xsd
appsettings.Development.json
.DS_Store
# CDK (infra/cdk) generated artifacts
infra/cdk/node_modules/
infra/cdk/dist/
infra/cdk/cdk.out/
infra/cdk/.cdk.staging/
# Deployment packaging artifacts
.artifacts/

View file

@ -0,0 +1,13 @@
{
"suppressions": [
{
"advisory": "GHSA-mh99-v99m-4gvg",
"package": "brace-expansion",
"introducedBy": "aws-cdk-lib@2.262.1",
"scope": "Build-time CDK synthesis only; no untrusted pattern input or runtime deployment artifact.",
"reason": "The vulnerable copy is bundled by the latest aws-cdk-lib release and cannot be overridden or updated independently. Dependabot monitors the pinned CDK dependency.",
"reviewBy": "2026-08-10",
"tracking": "SH-133"
}
]
}

View file

@ -0,0 +1,199 @@
using System.Net;
using Amazon.Runtime;
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Configuration;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class ProcurementWorkOrderClientTests
{
[Fact]
public async Task Signs_every_retry_with_session_credentials_and_reads_typed_page()
{
var handler = new RecordingHandler(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
Json(HttpStatusCode.OK,
"""{"items":[{"work_order_id":"123","updated_at":"2026-07-24T12:00:00Z","wo_status":"new"}],"next_cursor":"opaque"}"""));
var credentials = new RecordingCredentialsProvider();
var client = Create(handler, credentials);
var page = await client.GetWorkOrdersAsync(null, 100, CancellationToken.None);
Assert.Equal("123", Assert.Single(page.Items).WorkOrderId);
Assert.Equal("opaque", page.NextCursor);
Assert.Equal(2, handler.Requests.Count);
Assert.Equal(2, credentials.CallCount);
Assert.Equal(
"AWS4-HMAC-SHA256 Credential=access/20260724/us-east-1/execute-api/aws4_request, "
+ "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, "
+ "Signature=32f2ce331e25cf7e165126108eab8a0e7313fd051647df89ee8e658cfe4a967a",
handler.Requests[0].Authorization);
Assert.All(handler.Requests, request =>
{
Assert.Equal("procurement-api.seahaven.com", request.Uri.Host);
Assert.Contains("limit=100", request.Uri.Query);
Assert.StartsWith("AWS4-HMAC-SHA256 Credential=access/", request.Authorization);
Assert.Contains("/us-east-1/execute-api/aws4_request", request.Authorization);
Assert.Equal("session-token", request.SecurityToken);
});
}
[Fact]
public async Task Encodes_cursor_and_uses_separate_comment_path()
{
var handler = new RecordingHandler(Json(HttpStatusCode.OK,
"""{"items":[{"work_order_id":"123","comment_id":"c1","text":"mock"}],"next_cursor":null}"""));
var client = Create(handler, new RecordingCredentialsProvider());
var page = await client.GetCommentsAsync("123", "a/b+c=", 50, CancellationToken.None);
Assert.Equal("c1", Assert.Single(page.Items).CommentId);
Assert.Equal(
"/work-orders/123/comments?cursor=a%2Fb%2Bc%3D&limit=50",
Assert.Single(handler.Requests).Uri.PathAndQuery);
}
[Fact]
public async Task Retries_transient_network_failure_and_accepts_legacy_null_updated_at()
{
var handler = new ThrowingThenSuccessHandler(Json(HttpStatusCode.OK,
"""{"items":[{"work_order_id":"123","updated_at":null,"created_at":null}],"next_cursor":null}"""));
var credentials = new RecordingCredentialsProvider();
var client = Create(handler, credentials);
var page = await client.GetWorkOrdersAsync(null, 100, CancellationToken.None);
Assert.Null(Assert.Single(page.Items).UpdatedAt);
Assert.Equal(2, handler.CallCount);
Assert.Equal(2, credentials.CallCount);
}
[Fact]
public async Task Rejects_unapproved_origin_and_oversized_response()
{
var options = Options("https://attacker.invalid");
var client = new ProcurementWorkOrderClient(
new HttpClient(new RecordingHandler(Json(HttpStatusCode.OK, """{"items":[]}"""))),
new RecordingCredentialsProvider(),
options,
TimeProvider.System);
await Assert.ThrowsAsync<InvalidOperationException>(
() => client.GetWorkOrdersAsync(null, 100, CancellationToken.None));
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[1025])
});
var bounded = Create(handler, new RecordingCredentialsProvider(), maxResponseBytes: 1024);
await Assert.ThrowsAsync<InvalidDataException>(
() => bounded.GetWorkOrdersAsync(null, 100, CancellationToken.None));
}
private static ProcurementWorkOrderClient Create(
HttpMessageHandler handler,
IProcurementAwsCredentialsProvider credentials,
int maxResponseBytes = 4096) =>
new(
new HttpClient(handler),
credentials,
Options("https://procurement-api.seahaven.com", maxResponseBytes),
new FixedTimeProvider());
private static IOptionsMonitor<WorkOrderReconciliationOptions> Options(
string baseUrl,
int maxResponseBytes = 4096) =>
new TestOptions(new WorkOrderReconciliationOptions
{
Enabled = true,
BaseUrl = baseUrl,
MaxRetries = 1,
RetryBaseDelayMilliseconds = 0,
MaxResponseBytes = maxResponseBytes
});
private static HttpResponseMessage Json(HttpStatusCode status, string json) =>
new(status) { Content = new StringContent(json) };
private sealed class RecordingCredentialsProvider : IProcurementAwsCredentialsProvider
{
public int CallCount { get; private set; }
public Task<ImmutableCredentials> GetAsync(CancellationToken cancellationToken)
{
CallCount++;
return Task.FromResult(new ImmutableCredentials(
"access",
"secret",
"session-token"));
}
}
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
public RecordingHandler(params HttpResponseMessage[] responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
public List<RecordedRequest> Requests { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new RecordedRequest(
request.RequestUri!,
request.Headers.TryGetValues("Authorization", out var authorization)
? authorization.Single()
: string.Empty,
request.Headers.TryGetValues("X-Amz-Security-Token", out var values)
? values.Single()
: null));
return Task.FromResult(_responses.Dequeue());
}
}
private sealed class ThrowingThenSuccessHandler : HttpMessageHandler
{
private readonly HttpResponseMessage _response;
public ThrowingThenSuccessHandler(HttpResponseMessage response)
{
_response = response;
}
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
CallCount++;
return CallCount == 1
? Task.FromException<HttpResponseMessage>(
new HttpRequestException("transient mock failure"))
: Task.FromResult(_response);
}
}
private sealed record RecordedRequest(Uri Uri, string Authorization, string? SecurityToken);
private sealed class TestOptions : IOptionsMonitor<WorkOrderReconciliationOptions>
{
public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value;
public WorkOrderReconciliationOptions CurrentValue { get; }
public WorkOrderReconciliationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(
Action<WorkOrderReconciliationOptions, string?> listener) => null;
}
private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() =>
DateTimeOffset.Parse("2026-07-24T12:00:00Z");
}
}

View file

@ -0,0 +1,216 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using FluentValidation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterControllerTests
{
private static VendorCompanyRosterController NewController(Mock<IVendorCompanyRosterService> service, string? userId = "42")
{
var controller = new VendorCompanyRosterController(service.Object, Mock.Of<ILogger<VendorCompanyRosterController>>());
ClaimsIdentity identity;
if (userId == null)
identity = new ClaimsIdentity();
else
identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "Test");
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
};
return controller;
}
private static VendorRosterDTO SampleRoster() => new()
{
CompanyId = 7,
Name = "Acme",
Email = "acme@example.com",
RowVersion = "AAAAAAAAD8I=",
Technicians = new List<VendorRosterTechnicianDTO> { new() { Id = 1, ContactName = "Riley" } }
};
[Fact]
public async Task Get_Unauthenticated_Returns401()
{
var service = new Mock<IVendorCompanyRosterService>();
var controller = NewController(service, userId: null);
var result = await controller.Get(vendorId: 1, companyId: null, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Get_RosterFound_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(1, null, "42", It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
ok.Value.Should().BeEquivalentTo(SampleRoster());
}
[Fact]
public async Task Get_RosterMissing_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorRosterDTO?)null);
var result = await NewController(service).Get(vendorId: 99, companyId: null, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Get_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("A vendor id or company id is required."));
var result = await NewController(service).Get(vendorId: null, companyId: null, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task Create_Unauthenticated_Returns401()
{
var service = new Mock<IVendorCompanyRosterService>();
var controller = NewController(service, userId: null);
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
}
[Fact]
public async Task Create_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Reconcile_Unauthenticated_Returns401()
{
var service = new Mock<IVendorCompanyRosterService>();
var controller = NewController(service, userId: null);
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Reconcile_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Reconcile_OpenWorkOrderConflict_ReturnsStable409()
{
var blocked = new List<LinkedWorkOrderInfo>
{
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
};
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
}
[Fact]
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
}
[Fact]
public async Task Get_UnexpectedException_ReturnsSanitized500()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("secret stack details"));
var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None);
var serverError = result.Should().BeOfType<ObjectResult>().Subject;
serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
serverError.Value!.ToString()!.Should().NotContain("secret stack details");
}
}

View file

@ -0,0 +1,495 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Models;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterDataServiceTests
{
private static ApplicationDbContext NewContext(string dbName)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
return new ApplicationDbContext(options);
}
private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(ApplicationDbContext context, string companyName, params string[] technicianNames)
{
var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant() };
context.VendorCompanies.Add(company);
foreach (var name in technicianNames)
{
context.Vendors.Add(new Vendor
{
CompanyName = companyName,
ContactName = name,
IsActive = true,
IsDeleted = false,
Company = company
});
}
await context.SaveChangesAsync();
return (company.Id, context.Vendors.First(v => v.CompanyId == company.Id));
}
[Fact]
public async Task CreateRoster_PersistsCompanyAndTechniciansAtomically()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel
{
Name = "Atomic Co",
Email = "atomic@example.com",
Notes = "shared notes",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "T1" },
new() { ContactName = "T2" }
}
}, CancellationToken.None);
roster.CompanyId.Should().BeGreaterThan(0);
roster.Technicians.Should().HaveCount(2);
roster.Notes.Should().Be("shared notes");
var company = context.VendorCompanies.Single(c => c.Id == roster.CompanyId);
company.Name.Should().Be("Atomic Co");
context.Vendors.Count(v => v.CompanyId == company.Id).Should().Be(2);
context.Vendors.Where(v => v.CompanyId == company.Id).Should().OnlyContain(v => v.CompanyName == "Atomic Co");
}
[Fact]
public async Task GetRoster_ByVendorId_ResolvesCompanyAndAllTechnicians()
{
var dbName = Guid.NewGuid().ToString();
int companyId, vendorId;
using (var seed = NewContext(dbName))
{
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Resolve Co", "A", "B");
companyId = cid;
vendorId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "A").Id;
}
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
var roster = await service.GetRosterAsync(vendorId, null, CancellationToken.None);
roster.Should().NotBeNull();
roster!.CompanyId.Should().Be(companyId);
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "A", "B" });
}
[Fact]
public async Task GetRoster_WithMismatchedVendorAndCompanyIds_ReturnsNull()
{
var dbName = Guid.NewGuid().ToString();
int firstCompanyId, secondVendorId;
using (var seed = NewContext(dbName))
{
var (firstId, _) = await SeedCompanyWithTechnicianAsync(seed, "First Co", "A");
var (_, secondVendor) = await SeedCompanyWithTechnicianAsync(seed, "Second Co", "B");
firstCompanyId = firstId;
secondVendorId = secondVendor.Id;
}
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
var roster = await service.GetRosterAsync(secondVendorId, firstCompanyId, CancellationToken.None);
roster.Should().BeNull();
}
[Fact]
public async Task SaveRoster_RenamesCompanyAndTechnicianAndUpdatesNotes()
{
var dbName = Guid.NewGuid().ToString();
int companyId, techId;
using (var seed = NewContext(dbName))
{
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Old Co", "Old Name");
companyId = cid;
techId = vendor.Id;
}
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
var roster = await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
Name = "New Co",
Email = "new@example.com",
Notes = "updated notes",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>
{
new() { Id = techId, ContactName = "New Name", IsActive = true }
}
}, CancellationToken.None);
roster.Name.Should().Be("New Co");
roster.Notes.Should().Be("updated notes");
var tech = context.Vendors.Single(v => v.Id == techId);
tech.ContactName.Should().Be("New Name");
tech.CompanyName.Should().Be("New Co");
context.VendorCompanies.Single(c => c.Id == companyId).Notes.Should().Be("updated notes");
}
[Fact]
public async Task SaveRoster_SoftDeletesOmittedTechnicianWithoutOpenWorkOrders()
{
var dbName = Guid.NewGuid().ToString();
int companyId, keepId, dropId;
using (var seed = NewContext(dbName))
{
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Drop Co", "Keep", "Drop");
companyId = cid;
keepId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Keep").Id;
dropId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Drop").Id;
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
Name = "Drop Co",
Email = "drop@example.com",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>
{
new() { Id = keepId, ContactName = "Keep", IsActive = true }
}
}, CancellationToken.None);
}
using var verify = NewContext(dbName);
var kept = verify.Vendors.Single(v => v.Id == keepId);
kept.IsDeleted.Should().NotBeTrue();
var dropped = verify.Vendors.Single(v => v.Id == dropId);
dropped.IsDeleted.Should().BeTrue();
dropped.IsActive.Should().BeFalse();
}
[Fact]
public async Task SaveRoster_RemovingAllTechniciansLeavesZeroVendorsAndCompanyRemains()
{
var dbName = Guid.NewGuid().ToString();
int companyId;
using (var seed = NewContext(dbName))
{
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Empty Co", "T1", "T2");
companyId = cid;
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
Name = "Empty Co",
Email = "empty@example.com",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>()
}, CancellationToken.None);
}
using var verify = NewContext(dbName);
verify.Vendors.Count(v => v.CompanyId == companyId && (v.IsDeleted == null || v.IsDeleted == false)).Should().Be(0);
verify.VendorCompanies.Any(c => c.Id == companyId).Should().BeTrue();
}
[Fact]
public async Task SaveRoster_RemovalBlockedByOpenWorkOrdersFailsWholeSave()
{
var dbName = Guid.NewGuid().ToString();
int companyId, vendorId;
using (var seed = NewContext(dbName))
{
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Blocked Co", "Busy");
companyId = cid;
vendorId = vendor.Id;
seed.workOrders.Add(new WorkOrder { Id = 800, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled" });
seed.Dispatches.Add(new Dispatch { VendorId = vendorId, WorkOrderId = 800, Vendor = vendor });
await seed.SaveChangesAsync();
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
var actCall = () => service.SaveRosterAsync(new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 },
Name = "Blocked Co",
Email = "blocked@example.com",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>()
}, CancellationToken.None);
await actCall.Should().ThrowAsync<VendorRosterConflictException>();
}
using var verify = NewContext(dbName);
var tech = verify.Vendors.Single(v => v.Id == vendorId);
tech.IsDeleted.Should().NotBeTrue();
tech.IsActive.Should().BeTrue();
}
[Fact]
public async Task GetRoster_ForwardsCancellation()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
using var cts = new CancellationTokenSource();
cts.Cancel();
var act = () => service.GetRosterAsync(1, null, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task SaveRoster_PropagatesCompanyLocationAndPhoneToRetainedTechnician()
{
var dbName = Guid.NewGuid().ToString();
int companyId, techId;
using (var seed = NewContext(dbName))
{
var company = new VendorCompany
{
Name = "Loc Co",
NormalizedName = "loc co",
CompanyPhone = "(111) 111-0001",
Address = "Old Addr",
City = "OldCity",
State = "OS",
Zip = "00001",
GoogleMapsUrl = "https://old.example/map"
};
seed.VendorCompanies.Add(company);
seed.Vendors.Add(new Vendor
{
CompanyName = "Loc Co",
ContactName = "Keeper",
IsActive = true,
IsDeleted = false,
Company = company,
CompanyPhone = "(111) 111-0001",
Address = "Old Addr",
City = "OldCity",
State = "OS",
Zip = "00001",
GoogleMapsUrl = "https://old.example/map"
});
await seed.SaveChangesAsync();
companyId = company.Id;
techId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
Name = "Loc Co",
CompanyPhone = "(222) 222-0002",
Address = "New Addr",
City = "NewCity",
State = "NS",
Zip = "00002",
GoogleMapsUrl = "https://new.example/map",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>
{
new() { Id = techId, ContactName = "Keeper", IsActive = true }
}
}, CancellationToken.None);
}
using var verify = NewContext(dbName);
var tech = verify.Vendors.Single(v => v.Id == techId);
tech.CompanyPhone.Should().Be("(222) 222-0002");
tech.Address.Should().Be("New Addr");
tech.City.Should().Be("NewCity");
tech.State.Should().Be("NS");
tech.Zip.Should().Be("00002");
tech.GoogleMapsUrl.Should().Be("https://new.example/map");
var directory = new VendorDataService(verify);
var newCity = await directory.GetDirectoryPagedAsync(1, 10, search: "NewCity");
newCity.Items.Should().ContainSingle(row => row.VendorId == techId);
var oldCity = await directory.GetDirectoryPagedAsync(1, 10, search: "OldCity");
oldCity.Items.Should().NotContain(row => row.VendorId == techId);
}
[Fact]
public async Task SaveRoster_ClearsCompanyOwnedFieldsOnRetainedTechnicianWhenCompanyClearsThem()
{
var dbName = Guid.NewGuid().ToString();
int companyId, techId;
using (var seed = NewContext(dbName))
{
var company = new VendorCompany
{
Name = "Clear Co",
NormalizedName = "clear co",
CompanyPhone = "(333) 333-0003",
Address = "Keep Addr",
City = "KeepCity",
State = "KS",
Zip = "00003",
GoogleMapsUrl = "https://keep.example/map"
};
seed.VendorCompanies.Add(company);
seed.Vendors.Add(new Vendor
{
CompanyName = "Clear Co",
ContactName = "Keeper",
IsActive = true,
IsDeleted = false,
Company = company,
CompanyPhone = "(333) 333-0003",
Address = "Keep Addr",
City = "KeepCity",
State = "KS",
Zip = "00003",
GoogleMapsUrl = "https://keep.example/map"
});
await seed.SaveChangesAsync();
companyId = company.Id;
techId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
Name = "Clear Co",
CompanyPhone = null,
Address = null,
City = null,
State = null,
Zip = null,
GoogleMapsUrl = null,
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>
{
new() { Id = techId, ContactName = "Keeper", IsActive = true }
}
}, CancellationToken.None);
}
using var verify = NewContext(dbName);
var tech = verify.Vendors.Single(v => v.Id == techId);
tech.CompanyPhone.Should().BeNull();
tech.Address.Should().BeNull();
tech.City.Should().BeNull();
tech.State.Should().BeNull();
tech.Zip.Should().BeNull();
tech.GoogleMapsUrl.Should().BeNull();
}
[Fact]
public async Task SaveRoster_NewTechnicianReceivesCompanyPhoneAndLocation()
{
var dbName = Guid.NewGuid().ToString();
int companyId;
using (var seed = NewContext(dbName))
{
var company = new VendorCompany
{
Name = "Add Co",
NormalizedName = "add co"
};
seed.VendorCompanies.Add(company);
await seed.SaveChangesAsync();
companyId = company.Id;
}
int newTechId;
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
Name = "Add Co",
CompanyPhone = "(444) 444-0004",
Address = "Add Addr",
City = "AddCity",
State = "AS",
Zip = "00004",
GoogleMapsUrl = "https://add.example/map",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "Fresh", IsActive = true }
}
}, CancellationToken.None);
newTechId = act.Vendors.First(v => v.CompanyId == companyId && v.ContactName == "Fresh").Id;
}
using var verify = NewContext(dbName);
var tech = verify.Vendors.Single(v => v.Id == newTechId);
tech.CompanyPhone.Should().Be("(444) 444-0004");
tech.Address.Should().Be("Add Addr");
tech.City.Should().Be("AddCity");
tech.State.Should().Be("AS");
tech.Zip.Should().Be("00004");
tech.GoogleMapsUrl.Should().Be("https://add.example/map");
}
[Fact]
public async Task CreateRoster_NewTechnicianReceivesCompanyPhoneAndLocation()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel
{
Name = "Create Co",
CompanyPhone = "(555) 555-0005",
Address = "Create Addr",
City = "CreateCity",
State = "CS",
Zip = "00005",
GoogleMapsUrl = "https://create.example/map",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "Brand", IsActive = true }
}
}, CancellationToken.None);
var tech = context.Vendors.Single(v => v.CompanyId == roster.CompanyId);
tech.CompanyPhone.Should().Be("(555) 555-0005");
tech.Address.Should().Be("Create Addr");
tech.City.Should().Be("CreateCity");
tech.State.Should().Be("CS");
tech.Zip.Should().Be("00005");
tech.GoogleMapsUrl.Should().Be("https://create.example/map");
}
}

View file

@ -0,0 +1,383 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterServiceTests
{
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
{
CompanyId = companyId,
Name = "Acme",
CompanyPhone = "(312) 555-0199",
Email = "acme@example.com",
RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 },
Technicians = technicianIds.Select(id => new VendorRosterTechnicianReadModel { Id = id, ContactName = $"Tech {id}", IsActive = true }).ToList()
};
[Fact]
public async Task Get_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).GetRosterAsync(1, null, "", CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Get_WithoutVendorOrCompanyId_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).GetRosterAsync(null, null, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task Create_WithoutCompanyPhoneOrEmail_ThrowsContactGroupValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_InvalidCompanyPhone_DoesNotSatisfyContactGroup()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_RejectsExistingTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AcceptsZeroTechniciansWhenContactGroupPresent()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>()
}, "42", CancellationToken.None);
captured.Should().NotBeNull();
captured!.Technicians.Should().BeEmpty();
captured.Name.Should().Be("Acme");
}
[Fact]
public async Task Reconcile_RejectsDuplicateTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { Id = 5, ContactName = "Dup" },
new() { Id = 5, ContactName = "Dup Again" }
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_RejectsMismatchedTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1, 2));
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { Id = 1, ContactName = "Ok" },
new() { Id = 999, ContactName = "Foreign" }
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_RejectsNonHttpsMapUrl()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
GoogleMapsUrl = "http://maps.google.com/acme"
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
}
[Fact]
public async Task Create_NormalizesTechnicianPhoneBeforeForwarding()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
}
}, "42", CancellationToken.None);
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
}
[Fact]
public async Task Create_RejectsInvalidTechnicianPhoneFormat()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "Riley", Phone = "555-1234" }
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
}
[Fact]
public async Task Reconcile_ParsesRowVersionAndForwardsCompanyId()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel(7, 1));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Co",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I="));
captured.Name.Should().Be("Acme Co");
}
[Fact]
public async Task Reconcile_RejectsMalformedRowVersion()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7));
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "not-valid-base64!!",
Name = "Acme",
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_RejectsMissingRowVersion()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "",
Name = "Acme",
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_NullTechnicians_IsTreatedAsEmptyRoster()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = null!
};
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
captured!.Technicians.Should().BeEmpty();
}
[Theory]
[InlineData("Riley", "not-an-email", "Phone")]
[InlineData("Riley", "tech@example.com", "CarrierPigeon")]
public async Task Create_RejectsInvalidTechnicianFields(string name, string email, string preferredContact)
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = name, Email = email, PreferredContact = preferredContact }
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AcceptsTechnicianWithoutNameOrPhone()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel());
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
{
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
}
}, "42", CancellationToken.None);
captured!.Technicians.Should().ContainSingle();
captured.Technicians.Single().ContactName.Should().BeEmpty();
captured.Technicians.Single().Phone.Should().BeNullOrEmpty();
}
[Fact]
public async Task Reconcile_UnknownCompanyWithTechnicianIds_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 404, It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorCompanyRosterReadModel?)null);
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
};
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task Reconcile_ForwardsCancellation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource();
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", cts.Token);
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
}
}

View file

@ -33,6 +33,38 @@ public class VendorControllerTests
return controller;
}
[Fact]
public async Task GetVendorList_ForwardsRequestCancellationToken()
{
var service = new Mock<IVendorService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
service.Setup(x => x.GetVendorDirectoryPagedAsync(
1,
10,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>());
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
service.Verify(x => x.GetVendorDirectoryPagedAsync(
1,
10,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
}
[Fact]
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
{

View file

@ -3,6 +3,7 @@ using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Models;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
@ -321,4 +322,176 @@ public class VendorDataServiceTests
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
}
[Fact]
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
{
await using var context = NewContext();
var soloCompany = new VendorCompany { Name = "Solo Co", NormalizedName = "solo co", Notes = "company notes" };
var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" };
context.VendorCompanies.AddRange(soloCompany, techCompany);
context.Vendors.AddRange(
new Vendor
{
CompanyName = "Tech Co",
ContactName = "Pat",
City = "Austin",
State = "TX",
IsActive = true,
IsDeleted = false,
Company = techCompany
},
new Vendor
{
CompanyName = "Other",
ContactName = "No Company Tech",
IsActive = true,
IsDeleted = false,
CompanyId = null
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetDirectoryPagedAsync(1, 50, isActive: true);
totalCount.Should().Be(3);
var companyOnly = items.Single(i => i.CompanyName == "Solo Co");
companyOnly.VendorId.Should().BeNull();
companyOnly.CompanyId.Should().Be(soloCompany.Id);
companyOnly.Notes.Should().Be("company notes");
items.Should().Contain(i => i.ContactName == "Pat" && i.VendorId != null && i.CompanyId == techCompany.Id);
items.Should().Contain(i => i.ContactName == "No Company Tech" && i.VendorId != null);
}
[Theory]
[InlineData("trade")]
[InlineData("location")]
[InlineData("job-bucket")]
public void BuildDirectoryUnionQuery_TranslatesToSqlServerUnion(string filter)
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var query = filter switch
{
"trade" => service.BuildDirectoryUnionQuery(trades: new[] { "Plumbing" }),
"location" => service.BuildDirectoryUnionQuery(locations: new[] { "Chicago, IL" }),
_ => service.BuildDirectoryUnionQuery(jobBuckets: new[] { "under-50" })
};
var sql = query.ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("UNION");
if (filter == "trade")
sql.Should().Contain("TradeSpecialties");
if (filter == "location")
sql.Should().Contain("City");
if (filter == "job-bucket")
sql.Should().Contain("TotalJobs");
}
[Fact]
public async Task GetCompaniesForFacetsAsync_IncludesZeroTechnicianCompanyForActiveAndNullStatus()
{
await using var context = NewContext();
var rosterService = new VendorCompanyRosterDataService(context);
await rosterService.CreateRosterAsync(new VendorCompanyRosterWriteModel
{
Name = "Solo Co",
Email = "solo@example.com",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>()
}, CancellationToken.None);
var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" };
context.VendorCompanies.Add(techCompany);
context.Vendors.Add(new Vendor
{
CompanyName = "Tech Co",
ContactName = "Pat",
IsActive = true,
IsDeleted = false,
Company = techCompany
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" });
var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None);
noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" });
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
inactive.Should().BeEmpty();
}
[Fact]
public async Task GetCompaniesForFacetsAsync_InactiveStatusReturnsOnlyCompaniesWithInactiveTechnicians()
{
await using var context = NewContext();
var activeOnlyCo = new VendorCompany { Name = "Active Only Co", NormalizedName = "active only co" };
var inactiveOnlyCo = new VendorCompany { Name = "Inactive Only Co", NormalizedName = "inactive only co" };
var mixedCo = new VendorCompany { Name = "Mixed Co", NormalizedName = "mixed co" };
context.VendorCompanies.AddRange(activeOnlyCo, inactiveOnlyCo, mixedCo);
context.Vendors.AddRange(
new Vendor { CompanyName = "Active Only Co", IsActive = true, IsDeleted = false, Company = activeOnlyCo },
new Vendor { CompanyName = "Inactive Only Co", IsActive = false, IsDeleted = false, Company = inactiveOnlyCo },
new Vendor { CompanyName = "Mixed Co", IsActive = true, IsDeleted = false, Company = mixedCo },
new Vendor { CompanyName = "Mixed Co", IsActive = false, IsDeleted = false, Company = mixedCo });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
inactive.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Inactive Only Co", "Mixed Co" });
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Mixed Co" });
var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None);
noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Inactive Only Co", "Mixed Co" });
}
[Fact]
public async Task GetCompaniesForFacetsAsync_TreatsCompanyWithOnlyDeletedTechniciansAsZeroTechnician()
{
await using var context = NewContext();
var company = new VendorCompany { Name = "Ghost Co", NormalizedName = "ghost co" };
context.VendorCompanies.Add(company);
context.Vendors.Add(new Vendor
{
CompanyName = "Ghost Co",
IsActive = true,
IsDeleted = true,
Company = company
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
active.Single().Name.Should().Be("Ghost Co");
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
inactive.Should().BeEmpty();
}
[Theory]
[InlineData(true)]
[InlineData(false)]
[InlineData(null)]
public void BuildCompaniesForFacetsQuery_TranslatesToSqlServerWithoutClientEvaluation(bool? isActive)
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var sql = service.BuildCompaniesForFacetsQuery(isActive).ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("VendorCompanies");
}
}

View file

@ -35,6 +35,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
}
private VendorPortalController NewController(ApplicationDbContext context)
{
return NewController(NewService(context));
}
private VendorPortalService NewService(ApplicationDbContext context)
{
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(
@ -45,11 +50,18 @@ public sealed class VendorPortalDocumentTests : IDisposable
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
var service = new VendorPortalService(
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalCommentData>());
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
Mock.Of<IUpliftDataService>(),
Mock.Of<ICommentDataService>(),
upliftData.Object,
commentData.Object,
Mock.Of<IUserDataService>(),
Mock.Of<IEmailSender>(),
documentData,
@ -57,7 +69,10 @@ public sealed class VendorPortalDocumentTests : IDisposable
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
}
private static VendorPortalController NewController(VendorPortalService service)
{
var controller = new VendorPortalController(service, Mock.Of<ILogger<VendorPortalController>>());
controller.ControllerContext = new ControllerContext
{
@ -101,6 +116,28 @@ public sealed class VendorPortalDocumentTests : IDisposable
};
}
[Fact]
public void CompletionDocumentEndpoints_AdvertiseCanonicalAndCompatibilityRoutes()
{
var uploadRoutes = typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.UploadCompletionDocument))!
.GetCustomAttributes(typeof(HttpPostAttribute), inherit: false)
.Cast<HttpPostAttribute>()
.Select(attribute => attribute.Template);
var downloadRoutes = typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.DownloadCompletionDocument))!
.GetCustomAttributes(typeof(HttpGetAttribute), inherit: false)
.Cast<HttpGetAttribute>()
.Select(attribute => attribute.Template);
uploadRoutes.Should().BeEquivalentTo(
"dispatches/{id:int}/completion-documents",
"dispatches/{id:int}/documents");
downloadRoutes.Should().BeEquivalentTo(
"dispatches/{id:int}/completion-documents/{documentId:int}",
"dispatches/{id:int}/documents/{documentId:int}");
}
[Fact]
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
{
@ -131,6 +168,7 @@ public sealed class VendorPortalDocumentTests : IDisposable
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus.Should().Be("Pending");
document.ReviewStatus.Should().Be("Processing");
document.ReplacesDocumentId.Should().BeNull();
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
@ -154,6 +192,106 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public async Task UploadCompletionDocument_UsesExplicitOwnedReplacement()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var firstUpload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v1.pdf", "application/pdf"));
firstUpload.Should().BeOfType<OkObjectResult>();
var first = await context.VendorCompletionDocuments.SingleAsync();
var replacementUpload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v2.pdf", "application/pdf"),
first.Id);
replacementUpload.Should().BeOfType<OkObjectResult>();
var replacement = await context.VendorCompletionDocuments
.OrderBy(document => document.Version)
.LastAsync();
replacement.Version.Should().Be(2);
replacement.ReplacesDocumentId.Should().Be(first.Id);
}
[Fact]
public async Task UploadCompletionDocument_RejectsReplacementFromAnotherDispatch()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v1.pdf", "application/pdf"));
var existing = await context.VendorCompletionDocuments.SingleAsync();
var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other repair" };
context.Add(otherWorkOrder);
await context.SaveChangesAsync();
var otherDispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = otherWorkOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(otherDispatch);
await context.SaveChangesAsync();
var result = await controller.UploadCompletionDocument(
otherDispatch.Id,
FormFile(pdf, "replacement.pdf", "application/pdf"),
existing.Id);
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().ContainSingle();
}
[Fact]
public async Task UploadCompletionDocument_RejectsReplacementOwnedByAnotherVendor()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var otherVendor = new Vendor { CompanyName = "Other vendor", IsActive = true };
var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other vendor repair" };
context.AddRange(otherVendor, otherWorkOrder);
await context.SaveChangesAsync();
var otherDispatch = new Dispatch
{
VendorId = otherVendor.Id,
WorkOrderId = otherWorkOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(otherDispatch);
await context.SaveChangesAsync();
var otherDocument = new VendorCompletionDocument
{
VendorId = otherVendor.Id,
DispatchId = otherDispatch.Id,
WorkOrderId = otherWorkOrder.Id,
OriginalFileName = "other.pdf",
StoredFileName = "other.pdf",
ContentType = "application/pdf",
SizeBytes = 4,
Version = 1
};
context.VendorCompletionDocuments.Add(otherDocument);
await context.SaveChangesAsync();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("%PDF-1.4\nreplacement"u8.ToArray(), "replacement.pdf", "application/pdf"),
otherDocument.Id);
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().ContainSingle();
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
{
@ -169,6 +307,173 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var service = NewService(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
await NewController(service).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v1.pdf", "application/pdf"));
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var firstDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
firstDetail.Should().NotBeNull();
var firstDoc = firstDetail!.Documents.Should().ContainSingle().Subject;
firstDoc.OriginalFileName.Should().Be("completion-v1.pdf");
firstDoc.ContentType.Should().Be("application/pdf");
firstDoc.SizeBytes.Should().Be(pdf.Length);
firstDoc.ScanStatus.Should().Be("Pending");
firstDoc.ReviewStatus.Should().Be("Processing");
firstDoc.Version.Should().Be(1);
firstDoc.ReplacesDocumentId.Should().BeNull();
firstDoc.CanDownload.Should().BeFalse();
firstDoc.CreatedDate.Should().NotBeNull();
var first = await context.VendorCompletionDocuments.SingleAsync();
await NewController(service).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion-v2.pdf", "application/pdf"),
first.Id);
var secondDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
var ordered = secondDetail!.Documents.OrderByDescending(d => d.Version).ToList();
ordered.Should().HaveCount(2);
ordered[0].Version.Should().Be(2);
ordered[0].ReplacesDocumentId.Should().Be(first.Id);
ordered[0].OriginalFileName.Should().Be("completion-v2.pdf");
ordered[1].Version.Should().Be(1);
ordered[1].ReplacesDocumentId.Should().BeNull();
}
[Fact]
public async Task GetDispatchDetail_EnablesDownloadOnlyWhenScanPasses()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var service = NewService(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
await NewController(service).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "application/pdf"));
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus = "Passed";
document.ScannedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
var doc = detail!.Documents.Should().ContainSingle().Subject;
doc.ScanStatus.Should().Be("Passed");
doc.CanDownload.Should().BeTrue();
doc.ScannedAt.Should().NotBeNull();
}
[Theory]
[InlineData("Verified")]
[InlineData("Cancelled")]
public async Task GetDispatchDetail_DisablesDownloadForLockedDispatches(string status)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var service = NewService(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
await NewController(service).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "application/pdf"));
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus = "Passed";
dispatch.Status = status;
await context.SaveChangesAsync();
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.Documents.Should().ContainSingle().Which.CanDownload.Should().BeFalse();
}
[Fact]
public async Task GetDispatchDetail_ExcludesSoftDeletedDocuments()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var service = NewService(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
await NewController(service).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "application/pdf"));
var document = await context.VendorCompletionDocuments.SingleAsync();
document.IsDeleted = true;
await context.SaveChangesAsync();
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.Documents.Should().BeEmpty();
}
[Fact]
public async Task GetDispatchDetail_ReturnsInvoicePaymentFieldsOnlyForOwningVendor()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
dispatch.InvoiceNumber = "INV-2048";
dispatch.InvoiceStatus = "Confirmed";
dispatch.PaymentStatus = "Scheduled";
await context.SaveChangesAsync();
var service = NewService(context);
var ownerSession = await service.ResolveSessionAsync(Token, CancellationToken.None);
var ownerDetail = await service.GetDispatchDetailAsync(ownerSession!, dispatch.Id, CancellationToken.None);
ownerDetail.Should().NotBeNull();
ownerDetail!.InvoiceNumber.Should().Be("INV-2048");
ownerDetail.InvoiceStatus.Should().Be("Confirmed");
ownerDetail.PaymentStatus.Should().Be("Scheduled");
var ownerResult = await NewController(service).GetDispatch(dispatch.Id);
var ownerOk = ownerResult.Should().BeOfType<OkObjectResult>().Subject;
var ownerResponse = ownerOk.Value.Should().BeOfType<DataResponse>().Subject;
var ownerData = ((object)ownerResponse.Data)
.Should().BeOfType<SeaHaven.Services.DTOs.VendorDispatchDetailDTO>().Subject;
ownerData.InvoiceNumber.Should().Be("INV-2048");
ownerData.InvoiceStatus.Should().Be("Confirmed");
ownerData.PaymentStatus.Should().Be("Scheduled");
var otherVendor = new Vendor { CompanyName = "Other Vendor", IsActive = true };
context.Vendors.Add(otherVendor);
await context.SaveChangesAsync();
const string otherToken = "other-vendor-token";
context.VendorAccessTokens.Add(new VendorAccessToken
{
VendorId = otherVendor.Id,
Token = otherToken,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(1)
});
await context.SaveChangesAsync();
var otherSession = await service.ResolveSessionAsync(otherToken, CancellationToken.None);
var otherDetail = await service.GetDispatchDetailAsync(otherSession!, dispatch.Id, CancellationToken.None);
otherDetail.Should().BeNull();
var otherController = NewController(service);
otherController.Request.Headers["X-Vendor-Token"] = otherToken;
var otherResult = await otherController.GetDispatch(dispatch.Id);
otherResult.Should().BeOfType<NotFoundObjectResult>();
}
public void Dispose()
{
if (Directory.Exists(_contentRoot))

View file

@ -5,6 +5,7 @@ using FluentValidation;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
@ -146,6 +147,43 @@ public class VendorServiceTests
}, options => options.ExcludingMissingMembers());
}
[Fact]
public async Task GetVendorDirectoryPaged_ForwardsCancellationToken()
{
var data = new Mock<IVendorDataService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
data.Setup(x => x.GetDirectoryPagedAsync(
1,
12,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 0));
await NewService(data).GetVendorDirectoryPagedAsync(
1,
12,
"clayton",
true,
cancellationToken: token);
data.Verify(x => x.GetDirectoryPagedAsync(
1,
12,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
}
[Fact]
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
{
@ -209,20 +247,44 @@ public class VendorServiceTests
}
[Fact]
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
public void CreateVendor_AcceptsOptionalTechnicianButRequiresContactGroupAndHttpsUrl()
{
var validator = new CreateVendorValidation();
var result = validator.Validate(new CreateVendorDTO
// SH-199: technician (ContactName) is optional; zero-technician create is valid
// as long as a company phone or email is present and the map URL is HTTPS.
var valid = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "",
Email = "dispatch@gateway.example",
GoogleMapsUrl = "https://maps.google.com/gateway"
});
valid.IsValid.Should().BeTrue();
valid.Errors.Should().NotContain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
// Missing both company phone and email fails the contact-group rule.
var noContact = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "",
GoogleMapsUrl = "https://maps.google.com/gateway"
});
noContact.IsValid.Should().BeFalse();
noContact.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.CompanyPhone));
// Non-HTTPS map URL still rejected.
var badUrl = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
Email = "dispatch@gateway.example",
GoogleMapsUrl = "http://maps.google.com/gateway"
});
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
badUrl.IsValid.Should().BeFalse();
badUrl.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
}
[Fact]
@ -283,7 +345,8 @@ public class VendorServiceTests
{
Name = "Acme",
CompanyId = 77,
ContactName = "Riley"
ContactName = "Riley",
Email = "acme@example.com"
}, "42");
saved.Should().NotBeNull();
@ -327,7 +390,8 @@ public class VendorServiceTests
{
Name = "Acme",
ContactName = "Riley",
Phone = "+1 312 555 0100"
Phone = "+1 312 555 0100",
Email = "acme@example.com"
}, "42");
saved!.Phone.Should().Be("(312) 555-0100");
@ -488,4 +552,102 @@ public class VendorServiceTests
.WithMessage("*404*not found");
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task GetFacetsAsync_CompaniesAndLocationsUseCompanyOwnedFieldsOverTechnicianFields()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>
{
new()
{
Id = 1,
Name = "Owner Co",
CompanyPhone = "(555) 000-0001",
Email = "owner@example.com",
Address = "Owner Addr",
City = "OwnerCity",
State = "OS",
Zip = "00001",
GoogleMapsUrl = "https://owner.example/map"
}
});
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Vendor>
{
new()
{
Id = 10,
CompanyName = "Owner Co",
ContactName = "Pat",
City = "TechCity",
State = "TS",
Email = "tech@example.com",
CompanyPhone = "(555) 000-9999",
TradeSpecialties = "Plumbing"
}
});
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
var company = facets.Companies.Single();
company.Name.Should().Be("Owner Co");
company.CompanyId.Should().Be(1);
company.CompanyPhone.Should().Be("(555) 000-0001");
company.Email.Should().Be("owner@example.com");
company.Address.Should().Be("Owner Addr");
company.City.Should().Be("OwnerCity");
company.State.Should().Be("OS");
company.Zip.Should().Be("00001");
company.GoogleMapsUrl.Should().Be("https://owner.example/map");
company.PreferredContact.Should().BeNull();
var location = facets.Locations.Single();
location.City.Should().Be("OwnerCity");
location.State.Should().Be("OS");
location.Label.Should().Be("OwnerCity, OS");
facets.Trades.Should().BeEquivalentTo(new[] { "Plumbing" });
}
[Fact]
public async Task GetFacetsAsync_CompaniesAreUniqueAndOrderedCaseInsensitively()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>
{
new() { Id = 1, Name = "Beta Co" },
new() { Id = 2, Name = "alpha co" },
new() { Id = 3, Name = "Alpha Co" }
});
data.Setup(x => x.GetVendorsForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Vendor>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
// Case-insensitive dedup collapses "alpha co"/"Alpha Co" to one entry, and
// ordering places "alpha co" before "Beta Co" (case-insensitive, not Ordinal).
facets.Companies.Select(c => c.Name).Should().BeEquivalentTo(
new[] { "alpha co", "Beta Co" },
opts => opts.WithStrictOrdering());
}
[Fact]
public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany> { new() { Id = 1, Name = "No Trade Co" } });
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Vendor>
{
new() { Id = 20, CompanyName = "Trade Co", TradeSpecialties = "HVAC, Roofing" }
});
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
facets.Trades.Should().BeEquivalentTo(new[] { "HVAC", "Roofing" }, opts => opts.WithStrictOrdering());
}
}

View file

@ -0,0 +1,111 @@
using Api.SeaHavenIndustries.Controllers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class WorkOrderReconciliationControllerTests
{
[Fact]
public async Task Admin_trigger_returns_durable_run_and_status_is_queryable()
{
var runId = Guid.NewGuid();
var service = new StubService(runId);
var controller = new WorkOrderReconciliationController(service);
var trigger = Assert.IsType<AcceptedResult>(
await controller.Trigger(CancellationToken.None));
Assert.Equal(StatusCodes.Status202Accepted, trigger.StatusCode);
Assert.Equal(runId, service.LastRunId);
var status = Assert.IsType<OkObjectResult>(
await controller.Status(CancellationToken.None));
Assert.IsType<WorkOrderReconciliationStatus>(status.Value);
}
[Fact]
public async Task Disabled_trigger_returns_sanitized_unavailable_response()
{
var controller = new WorkOrderReconciliationController(new StubService(Guid.Empty));
var result = Assert.IsType<ObjectResult>(
await controller.Trigger(CancellationToken.None));
Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode);
Assert.DoesNotContain("exception", result.Value!.ToString(), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Framework_action_descriptors_expose_only_admin_get_and_post_reconciliation_routes()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore()
.AddApplicationPart(typeof(WorkOrderReconciliationController).Assembly);
using var provider = services.BuildServiceProvider();
var descriptors = provider
.GetRequiredService<IActionDescriptorCollectionProvider>()
.ActionDescriptors
.Items
.OfType<ControllerActionDescriptor>()
.Where(d => d.ControllerTypeInfo == typeof(WorkOrderReconciliationController))
.ToList();
var routes = descriptors
.SelectMany(d => (d.ActionConstraints ?? Array.Empty<IActionConstraintMetadata>())
.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods)
.Select(method => $"{method} {d.AttributeRouteInfo!.Template}"))
.ToHashSet(StringComparer.Ordinal);
Assert.True(routes.SetEquals(new[]
{
"GET api/admin/work-order-reconciliation",
"POST api/admin/work-order-reconciliation"
}));
Assert.All(descriptors, descriptor =>
Assert.Contains(
descriptor.ControllerTypeInfo.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.OfType<AuthorizeAttribute>(),
attribute => attribute.Roles == "Admin"));
}
private sealed class StubService : IWorkOrderReconciliationService
{
private readonly Guid _runId;
public StubService(Guid runId) => _runId = runId;
public Guid LastRunId { get; private set; }
public Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
ClaimsPrincipal user,
string reason,
CancellationToken cancellationToken)
{
LastRunId = _runId;
return Task.FromResult(new WorkOrderReconciliationTriggerResult(
_runId != Guid.Empty,
_runId));
}
public Task<WorkOrderReconciliationStatus> GetStatusAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult(new WorkOrderReconciliationStatus(
_runId,
"Pending",
"admin",
DateTimeOffset.UtcNow,
null,
null,
0,
0,
null));
}
}

View file

@ -0,0 +1,116 @@
using Api.SeaHavenIndustries.Controllers;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Framework-backed route contract tests that prove the public procurement webhook endpoint is
/// exactly <c>POST api/webhooks/work-orders</c> exposed by
/// <see cref="WorkOrderWebhookController.Receive"/>, that it is anonymous (no JWT required), and
/// that it is constrained to <c>application/json</c>. Routes and metadata are read from the
/// ASP.NET Core action descriptor provider so the EXACT templates and attributes the framework
/// will dispatch are compared.
/// </summary>
public class WorkOrderWebhookRouteContractTests
{
private readonly ITestOutputHelper _output;
public WorkOrderWebhookRouteContractTests(ITestOutputHelper output)
{
_output = output;
}
private static IReadOnlyList<ControllerActionDescriptor> WebhookActions()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore()
.AddApplicationPart(typeof(WorkOrderWebhookController).Assembly);
using var provider = services.BuildServiceProvider();
var actionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
return actionProvider.ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderWebhookController))
.ToList();
}
private static IReadOnlyList<string> VerbAndTemplates(ControllerActionDescriptor cad)
{
var template = cad.AttributeRouteInfo?.Template?.Trim('/');
var methods = (cad.ActionConstraints ?? Array.Empty<IActionConstraintMetadata>())
.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(m => m.ToUpperInvariant());
return methods.Select(m => $"{m} {template}").ToList();
}
[Fact]
public void WorkOrderWebhook_exposes_exactly_post_api_webhooks_work_orders()
{
var actions = WebhookActions();
actions.Should().ContainSingle(
"the webhook controller must expose exactly one action");
var receive = actions.Single();
receive.ActionName.Should().Be(
nameof(WorkOrderWebhookController.Receive),
"the single webhook action must be Receive");
var endpoints = VerbAndTemplates(receive);
Dump(endpoints);
endpoints.Should().BeEquivalentTo(
new[] { "POST api/webhooks/work-orders" },
"the procurement webhook must be reachable only as POST api/webhooks/work-orders");
}
[Fact]
public void WorkOrderWebhook_Receive_is_anonymous()
{
var receive = WebhookActions().Single(a =>
a.ActionName == nameof(WorkOrderWebhookController.Receive));
var hasAllowAnonymous = receive.ControllerTypeInfo
.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Any()
|| receive.MethodInfo
.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Any();
hasAllowAnonymous.Should().BeTrue(
"the webhook must accept anonymous delivery and must not require a JWT bearer token");
}
[Fact]
public void WorkOrderWebhook_Receive_consumes_application_json_only()
{
var receive = WebhookActions().Single(a =>
a.ActionName == nameof(WorkOrderWebhookController.Receive));
var contentTypes = receive.MethodInfo
.GetCustomAttributes(typeof(ConsumesAttribute), inherit: true)
.Cast<ConsumesAttribute>()
.SelectMany(a => a.ContentTypes)
.Select(c => c.ToString())
.ToList();
contentTypes.Should().BeEquivalentTo(
new[] { "application/json" },
"the webhook must be constrained to application/json payloads");
}
private void Dump(IEnumerable<string> endpoints)
{
_output.WriteLine("WorkOrderWebhook public endpoints (verb + route):");
foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal))
_output.WriteLine(" " + endpoint);
}
}

View file

@ -0,0 +1,216 @@
using System.Text.Json;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class WorkOrderWebhookSecretProviderTests
{
private const string SecretId = "workorder-ingest/shoc-webhook-hmac";
private static readonly string CurrentKeyset = KeysetDocument(
("2026-07-20T00", Hex(64, 'a')));
private static readonly string RotatedKeyset = KeysetDocument(
("2026-08-20T00", Hex(64, 'b')),
("2026-07-20T00", Hex(64, 'a')));
[Fact]
public async Task Known_key_is_cached_and_callers_receive_independent_secret_copies()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.Is<GetSecretValueRequest>(r => r.SecretId == SecretId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse(CurrentKeyset));
var provider = CreateProvider(client.Object, SecretId);
var first = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Found, first.Status);
Assert.NotNull(first.Secret);
first.Secret![0] = 0;
var second = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Found, second.Status);
Assert.Equal(Hex(64, 'a'), EncodingText(second.Secret!));
client.Verify(
c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Unknown_key_forces_one_refresh_then_rejects_with_unknown()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.Is<GetSecretValueRequest>(r => r.SecretId == SecretId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse(CurrentKeyset));
var provider = CreateProvider(client.Object, SecretId);
// Prime the cache with a known key first so the second lookup exercises
// the refresh-on-unknown path.
await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
var result = await provider.GetSecretAsync(
"attacker-controlled-key",
CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.UnknownKey, result.Status);
// One prime + exactly one refresh on the unknown kid.
client.Verify(
c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
[Fact]
public async Task Rotation_is_discovered_after_cache_expiry()
{
var call = 0;
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => ++call == 1 ? SecretResponse(CurrentKeyset) : SecretResponse(RotatedKeyset));
var provider = CreateProvider(client.Object, SecretId, cacheSeconds: 1);
var before = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(Hex(64, 'a'), EncodingText(before.Secret!));
await Task.Delay(TimeSpan.FromMilliseconds(1100));
var after = await provider.GetSecretAsync("2026-08-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Found, after.Status);
Assert.Equal(Hex(64, 'b'), EncodingText(after.Secret!));
}
[Fact]
public async Task Malformed_keyset_returns_unavailable()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse("not-json"));
var provider = CreateProvider(client.Object, SecretId);
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
Assert.Null(result.Secret);
}
[Fact]
public async Task Duplicate_kid_returns_unavailable()
{
var duplicate = KeysetDocument(
("2026-07-20T00", Hex(64, 'a')),
("2026-07-20T00", Hex(64, 'b')));
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(SecretResponse(duplicate));
var provider = CreateProvider(client.Object, SecretId);
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
}
[Fact]
public async Task Aws_failure_returns_unavailable_without_exposing_the_exception()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
client.Setup(c => c.GetSecretValueAsync(
It.IsAny<GetSecretValueRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("sensitive provider detail"));
var provider = CreateProvider(client.Object, SecretId);
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
Assert.Null(result.Secret);
}
[Fact]
public async Task Empty_secret_id_does_not_invoke_aws_and_returns_unavailable()
{
var client = new Mock<IAmazonSecretsManager>(MockBehavior.Strict);
var provider = CreateProvider(client.Object, secretId: "");
var result = await provider.GetSecretAsync("2026-07-20T00", CancellationToken.None);
Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status);
client.VerifyNoOtherCalls();
}
private static AwsWorkOrderWebhookSecretProvider CreateProvider(
IAmazonSecretsManager client,
string secretId,
int cacheSeconds = 300) =>
new(
client,
new TestOptionsMonitor(new WorkOrderWebhookOptions
{
SecretCacheSeconds = cacheSeconds,
SecretId = secretId
}),
TimeProvider.System,
NullLogger<AwsWorkOrderWebhookSecretProvider>.Instance);
private static GetSecretValueResponse SecretResponse(string secretString) =>
new() { SecretString = secretString };
private static string KeysetDocument(params (string Kid, string Secret)[] keys)
{
using var buffer = new MemoryStream();
using var writer = new Utf8JsonWriter(buffer);
writer.WriteStartObject();
writer.WritePropertyName("keys");
writer.WriteStartArray();
foreach (var (kid, secret) in keys)
{
writer.WriteStartObject();
writer.WriteString("kid", kid);
writer.WriteString("secret", secret);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return System.Text.Encoding.UTF8.GetString(buffer.ToArray());
}
private static string Hex(int length, char digit) => new(digit, length);
private static string EncodingText(byte[] secret) =>
System.Text.Encoding.UTF8.GetString(secret);
private sealed class TestOptionsMonitor : IOptionsMonitor<WorkOrderWebhookOptions>
{
public TestOptionsMonitor(WorkOrderWebhookOptions currentValue)
{
CurrentValue = currentValue;
}
public WorkOrderWebhookOptions CurrentValue { get; }
public WorkOrderWebhookOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(
Action<WorkOrderWebhookOptions, string?> listener) => null;
}
}

View file

@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="AWSSDK.DynamoDBv2" Version="4.0.17.9" />
<PackageReference Include="AWSSDK.SecretsManager" Version="4.0.100.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -0,0 +1,143 @@
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/vendor-company-roster")]
public class VendorCompanyRosterController : Controller
{
private readonly IVendorCompanyRosterService _rosterService;
private readonly ILogger<VendorCompanyRosterController> _logger;
public VendorCompanyRosterController(
IVendorCompanyRosterService rosterService,
ILogger<VendorCompanyRosterController> logger)
{
_rosterService = rosterService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] int? vendorId,
[FromQuery] int? companyId,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.GetRosterAsync(vendorId, companyId, userId, cancellationToken);
if (roster == null)
return NotFound(new Response { Status = "Error", Message = "Vendor roster not found" });
return Ok(roster);
}
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 Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateVendorRosterDTO model, CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken);
return Ok(roster);
}
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 Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPut("{companyId:int}")]
public async Task<IActionResult> Reconcile(
int companyId,
[FromBody] ReconcileVendorRosterDTO model,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken);
return Ok(roster);
}
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 Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
}
catch (VendorRosterConflictException conflict)
{
return Conflict(new
{
Status = "Conflict",
Message = _logger.Sanitize(conflict, "Vendor roster changes conflict with open work orders"),
BlockedWorkOrders = conflict.BlockedWorkOrders
});
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new
{
Status = "Conflict",
Message = "The vendor company was modified by another user. Refresh and retry.",
Code = 409
});
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
}
}

View file

@ -35,9 +35,10 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] string[]? companies = null,
[FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null)
[FromQuery] string[]? jobBuckets = null,
CancellationToken cancellationToken = default)
{
var pagedResult = await _vendorService.GetVendorsPagedAsync(
var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync(
page,
pageSize,
search,
@ -45,12 +46,13 @@ namespace Api.SeaHavenIndustries.Controllers
companies,
trades,
locations,
jobBuckets);
jobBuckets,
cancellationToken);
var data = pagedResult.Items.Select(v => new
{
v.Id,
CompanyName = v.Name,
CompanyName = v.CompanyName,
v.CompanyId,
v.ContactName,
v.Email,

View file

@ -228,15 +228,25 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost("dispatches/{id:int}/completion-documents")]
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(10_000_000)]
public async Task<IActionResult> UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default)
public async Task<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,
[FromForm] int? replacesDocumentId = null,
CancellationToken cancellationToken = default)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
{
var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken);
var result = await _portalService.UploadCompletionDocumentAsync(
session,
id,
file,
replacesDocumentId,
cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (KeyNotFoundException)
@ -250,6 +260,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")]
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default)
{
var session = await ResolveSessionAsync(cancellationToken);

View file

@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/admin/work-order-reconciliation")]
public sealed class WorkOrderReconciliationController : ControllerBase
{
private readonly IWorkOrderReconciliationService _service;
public WorkOrderReconciliationController(IWorkOrderReconciliationService service)
{
_service = service;
}
[HttpPost]
public async Task<IActionResult> Trigger(CancellationToken cancellationToken)
{
var result = await _service.TriggerAsync(User, "admin", cancellationToken);
if (!result.Queued && result.RunId == Guid.Empty)
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "disabled" });
return Accepted(new { run_id = result.RunId, queued = result.Queued });
}
[HttpGet]
public async Task<IActionResult> Status(CancellationToken cancellationToken) =>
Ok(await _service.GetStatusAsync(User, cancellationToken));
}
}

View file

@ -0,0 +1,114 @@
using System.Buffers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[ApiController]
[AllowAnonymous]
[Route("api/webhooks/work-orders")]
public sealed class WorkOrderWebhookController : ControllerBase
{
private readonly IWorkOrderWebhookService _service;
public WorkOrderWebhookController(IWorkOrderWebhookService service)
{
_service = service;
}
[HttpPost]
[Consumes("application/json")]
public async Task<IActionResult> Receive(CancellationToken cancellationToken)
{
if (!HasSupportedContentType(Request.ContentType))
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
if (Request.Headers.ContentEncoding.Count > 0
&& !string.Equals(
Request.Headers.ContentEncoding.ToString(),
"identity",
StringComparison.OrdinalIgnoreCase))
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
var maxBytes = _service.MaximumBodyBytes;
if (Request.ContentLength > maxBytes)
return StatusCode(StatusCodes.Status413PayloadTooLarge);
var body = await ReadBoundedBodyAsync(Request.Body, maxBytes, cancellationToken);
if (body == null)
return StatusCode(StatusCodes.Status413PayloadTooLarge);
var result = await _service.ProcessAsync(
new WorkOrderWebhookRequest(
SingleHeader("X-SH-Timestamp"),
SingleHeader("X-SH-Key-Id"),
SingleHeader("X-SH-Signature"),
body),
cancellationToken);
return result.Status switch
{
WorkOrderWebhookStatus.Applied => Ok(new
{
status = "accepted",
state_mutation_skipped = result.StateMutationSkipped
}),
WorkOrderWebhookStatus.Duplicate => Ok(new { status = "duplicate" }),
WorkOrderWebhookStatus.Unauthorized => Unauthorized(new { error = "unauthorized" }),
WorkOrderWebhookStatus.Disabled => StatusCode(
StatusCodes.Status503ServiceUnavailable,
new { error = "webhook unavailable" }),
WorkOrderWebhookStatus.InvalidEnvelope => BadRequest(new { error = "invalid envelope" }),
WorkOrderWebhookStatus.HashConflict => Conflict(new { error = "delivery conflict" }),
WorkOrderWebhookStatus.Unavailable => StatusCode(
StatusCodes.Status503ServiceUnavailable,
new { error = "webhook unavailable" }),
_ => StatusCode(StatusCodes.Status503ServiceUnavailable)
};
}
private string? SingleHeader(string name)
{
var values = Request.Headers[name];
return values.Count == 1 ? values[0] : null;
}
private static bool HasSupportedContentType(string? contentType)
{
return MediaTypeHeaderValue.TryParse(contentType, out var parsed)
&& string.Equals(
parsed.MediaType.Value,
"application/json",
StringComparison.OrdinalIgnoreCase);
}
private static async Task<byte[]?> ReadBoundedBodyAsync(
Stream source,
int maxBytes,
CancellationToken cancellationToken)
{
var rented = ArrayPool<byte>.Shared.Rent(81920);
try
{
using var destination = new MemoryStream(Math.Min(maxBytes, 81920));
while (true)
{
var read = await source.ReadAsync(rented.AsMemory(0, rented.Length), cancellationToken);
if (read == 0)
return destination.ToArray();
if (destination.Length + read > maxBytes)
return null;
await destination.WriteAsync(rented.AsMemory(0, read), cancellationToken);
}
}
finally
{
ArrayPool<byte>.Shared.Return(rented, clearArray: true);
}
}
}
}

View file

@ -0,0 +1,85 @@
using Microsoft.Extensions.Options;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.HostedServices
{
public sealed class WorkOrderReconciliationHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<WorkOrderReconciliationOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<WorkOrderReconciliationHostedService> _logger;
public WorkOrderReconciliationHostedService(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<WorkOrderReconciliationOptions> options,
TimeProvider timeProvider,
ILogger<WorkOrderReconciliationHostedService> logger)
{
_scopeFactory = scopeFactory;
_options = options;
_timeProvider = timeProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var nextScheduledAt = _timeProvider.GetUtcNow();
if (_options.CurrentValue.Enabled && _options.CurrentValue.RunOnStartup)
{
await TriggerAsync("startup", stoppingToken);
nextScheduledAt = _timeProvider.GetUtcNow().AddMinutes(_options.CurrentValue.ScheduleMinutes);
}
while (!stoppingToken.IsCancellationRequested)
{
var options = _options.CurrentValue;
if (options.Enabled)
{
var now = _timeProvider.GetUtcNow();
if (now >= nextScheduledAt)
{
await TriggerAsync("schedule", stoppingToken);
nextScheduledAt = now.AddMinutes(options.ScheduleMinutes);
}
await RunPendingAsync(stoppingToken);
}
await Task.Delay(
TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 300)),
_timeProvider,
stoppingToken);
}
}
private async Task TriggerAsync(string reason, CancellationToken cancellationToken)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var runner = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationRunner>();
await runner.TriggerAsync(reason, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Could not queue procurement reconciliation.");
}
}
private async Task RunPendingAsync(CancellationToken cancellationToken)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var runner = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationRunner>();
await runner.RunPendingAsync(cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Could not execute procurement reconciliation.");
}
}
}
}

View file

@ -0,0 +1,247 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
public sealed class AwsWorkOrderWebhookSecretProvider : IWorkOrderWebhookSecretProvider
{
private const int MaxKeys = 16;
private const int MaxKidLength = 128;
private const int SecretHexLength = 64;
private const int MaxSecretDocumentLength = 32 * 1024;
private readonly IAmazonSecretsManager _client;
private readonly IOptionsMonitor<WorkOrderWebhookOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AwsWorkOrderWebhookSecretProvider> _logger;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private CachedKeyset? _cache;
public AwsWorkOrderWebhookSecretProvider(
IAmazonSecretsManager client,
IOptionsMonitor<WorkOrderWebhookOptions> options,
TimeProvider timeProvider,
ILogger<AwsWorkOrderWebhookSecretProvider> logger)
{
_client = client;
_options = options;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<WorkOrderWebhookSecretResult> GetSecretAsync(
string keyId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(keyId) || keyId.Length > MaxKidLength)
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
var observed = _cache;
var now = _timeProvider.GetUtcNow();
if (observed != null && observed.ExpiresAt > now)
{
var match = observed.Lookup(keyId);
if (match != null)
return FoundCopy(match);
}
var refresh = await RefreshAsync(observed, cancellationToken);
if (refresh.Status == RefreshStatus.Unavailable)
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
var keyset = refresh.Keyset;
var lookup = keyset?.Lookup(keyId);
if (lookup != null)
return FoundCopy(lookup);
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
}
private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) =>
new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone());
private async Task<RefreshResult> RefreshAsync(
CachedKeyset? observed,
CancellationToken cancellationToken)
{
var secretId = _options.CurrentValue.SecretId;
if (string.IsNullOrWhiteSpace(secretId))
return new RefreshResult(RefreshStatus.Unavailable, null);
await _refreshLock.WaitAsync(cancellationToken);
try
{
if (!ReferenceEquals(_cache, observed))
return new RefreshResult(RefreshStatus.Refreshed, _cache);
CachedKeyset? replacement;
try
{
var response = await _client.GetSecretValueAsync(
new GetSecretValueRequest { SecretId = secretId },
cancellationToken);
replacement = ParseKeyset(response);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
_logger.LogError("Work-order webhook signing secret retrieval failed.");
return new RefreshResult(RefreshStatus.Unavailable, null);
}
if (replacement == null)
return new RefreshResult(RefreshStatus.Unavailable, null);
var options = _options.CurrentValue;
var ttlSeconds = Math.Clamp(options.SecretCacheSeconds, 1, 300);
replacement.SetExpiry(_timeProvider.GetUtcNow().AddSeconds(ttlSeconds));
ReplaceCache(observed, replacement);
return new RefreshResult(RefreshStatus.Refreshed, replacement);
}
finally
{
_refreshLock.Release();
}
}
private void ReplaceCache(CachedKeyset? prior, CachedKeyset replacement)
{
if (Interlocked.CompareExchange(ref _cache, replacement, prior) == prior)
{
if (prior != null)
prior.Zero();
}
else
{
replacement.Zero();
}
}
private enum RefreshStatus
{
Refreshed,
Unavailable
}
private readonly record struct RefreshResult(RefreshStatus Status, CachedKeyset? Keyset);
private static CachedKeyset? ParseKeyset(GetSecretValueResponse response)
{
if (string.IsNullOrEmpty(response.SecretString)
|| response.SecretString.Length > MaxSecretDocumentLength)
return null;
Dictionary<string, byte[]>? byKid = null;
try
{
using var document = JsonDocument.Parse(response.SecretString);
if (!document.RootElement.TryGetProperty("keys", out var keysElement)
|| keysElement.ValueKind != JsonValueKind.Array)
return null;
byKid = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (var item in keysElement.EnumerateArray())
{
if (byKid.Count >= MaxKeys)
return InvalidKeyset(byKid);
if (item.ValueKind != JsonValueKind.Object)
return InvalidKeyset(byKid);
if (!item.TryGetProperty("kid", out var kidElement)
|| kidElement.ValueKind != JsonValueKind.String)
return InvalidKeyset(byKid);
if (!item.TryGetProperty("secret", out var secretElement)
|| secretElement.ValueKind != JsonValueKind.String)
return InvalidKeyset(byKid);
var kid = kidElement.GetString();
var secret = secretElement.GetString();
if (string.IsNullOrWhiteSpace(kid)
|| kid.Length > MaxKidLength
|| secret == null
|| secret.Length != SecretHexLength
|| !IsAsciiHex(secret))
return InvalidKeyset(byKid);
if (byKid.ContainsKey(kid))
return InvalidKeyset(byKid);
byKid.Add(kid, Encoding.UTF8.GetBytes(secret));
}
}
catch (JsonException)
{
if (byKid != null)
ZeroSecrets(byKid);
return null;
}
if (byKid.Count == 0)
return null;
return new CachedKeyset(byKid);
}
private static CachedKeyset? InvalidKeyset(Dictionary<string, byte[]> byKid)
{
ZeroSecrets(byKid);
return null;
}
private static void ZeroSecrets(Dictionary<string, byte[]> byKid)
{
foreach (var secret in byKid.Values)
CryptographicOperations.ZeroMemory(secret);
}
private static bool IsAsciiHex(string value)
{
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
if (!IsHexDigit(c))
return false;
}
return true;
}
private static bool IsHexDigit(char c) =>
(uint)(c - '0') <= 9u
|| (uint)(c - 'a') <= 5u
|| (uint)(c - 'A') <= 5u;
private sealed class CachedKeyset
{
private readonly Dictionary<string, byte[]> _byKid;
private DateTimeOffset _expiresAt;
public DateTimeOffset ExpiresAt => _expiresAt;
public CachedKeyset(Dictionary<string, byte[]> byKid)
{
_byKid = byKid;
}
public void SetExpiry(DateTimeOffset expiresAt) => _expiresAt = expiresAt;
public byte[]? Lookup(string kid)
{
_byKid.TryGetValue(kid, out var secret);
return secret;
}
public void Zero()
{
ZeroSecrets(_byKid);
}
}
}
}

View file

@ -0,0 +1,332 @@
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Amazon.Runtime;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
internal interface IProcurementAwsCredentialsProvider
{
Task<ImmutableCredentials> GetAsync(CancellationToken cancellationToken);
}
internal sealed class DefaultProcurementAwsCredentialsProvider
: IProcurementAwsCredentialsProvider
{
public async Task<ImmutableCredentials> GetAsync(CancellationToken cancellationToken)
{
var credentials = FallbackCredentialsFactory.GetCredentials();
return await credentials.GetCredentialsAsync().WaitAsync(cancellationToken);
}
}
internal sealed class ProcurementWorkOrderClient : IProcurementWorkOrderClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
private readonly HttpClient _httpClient;
private readonly IProcurementAwsCredentialsProvider _credentials;
private readonly IOptionsMonitor<WorkOrderReconciliationOptions> _options;
private readonly TimeProvider _timeProvider;
public ProcurementWorkOrderClient(
HttpClient httpClient,
IProcurementAwsCredentialsProvider credentials,
IOptionsMonitor<WorkOrderReconciliationOptions> options,
TimeProvider timeProvider)
{
_httpClient = httpClient;
_credentials = credentials;
_options = options;
_timeProvider = timeProvider;
}
public Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
string? cursor,
int limit,
CancellationToken cancellationToken) =>
GetPageAsync<ProcurementWorkOrder>("/work-orders", cursor, limit, cancellationToken);
public Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
string workOrderId,
string? cursor,
int limit,
CancellationToken cancellationToken)
{
if (workOrderId.Length is 0 or > 64 || workOrderId.Any(c => c is < '0' or > '9'))
throw new ArgumentException("Work-order ID must be numeric.", nameof(workOrderId));
return GetPageAsync<ProcurementWorkOrderComment>(
$"/work-orders/{workOrderId}/comments",
cursor,
limit,
cancellationToken);
}
private async Task<ProcurementPage<T>> GetPageAsync<T>(
string path,
string? cursor,
int limit,
CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (limit is < 1 or > 500)
throw new ArgumentOutOfRangeException(nameof(limit));
if (cursor is { Length: > 0 } && cursor.Length > options.MaxCursorLength)
throw new ArgumentException("Cursor is too long.", nameof(cursor));
var origin = ValidateOrigin(options.BaseUrl);
var query = new List<KeyValuePair<string, string>>
{
new("limit", limit.ToString(CultureInfo.InvariantCulture))
};
if (cursor != null)
query.Add(new("cursor", cursor));
var canonicalQuery = string.Join(
"&",
query.OrderBy(k => k.Key, StringComparer.Ordinal)
.Select(k => $"{Encode(k.Key)}={Encode(k.Value)}"));
var uri = new Uri(origin, $"{path}?{canonicalQuery}");
for (var attempt = 0; ; attempt++)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(options.RequestTimeoutSeconds));
HttpResponseMessage response;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
await SignAsync(request, path, canonicalQuery, options.Region, timeout.Token);
response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
timeout.Token);
}
catch (OperationCanceledException) when (
!cancellationToken.IsCancellationRequested
&& attempt < options.MaxRetries)
{
await DelayBeforeRetryAsync(attempt, options, cancellationToken);
continue;
}
catch (HttpRequestException) when (attempt < options.MaxRetries)
{
await DelayBeforeRetryAsync(attempt, options, cancellationToken);
continue;
}
using (response)
{
if ((response.StatusCode == HttpStatusCode.TooManyRequests
|| (int)response.StatusCode >= 500)
&& attempt < options.MaxRetries)
{
await DelayBeforeRetryAsync(attempt, options, cancellationToken);
continue;
}
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(
$"Procurement API returned HTTP {(int)response.StatusCode}.",
null,
response.StatusCode);
var bytes = await ReadBoundedAsync(
response.Content,
options.MaxResponseBytes,
timeout.Token);
PageEnvelope<T>? envelope;
try
{
envelope = JsonSerializer.Deserialize<PageEnvelope<T>>(bytes, JsonOptions);
}
catch (JsonException ex)
{
throw new InvalidDataException("Procurement API returned invalid JSON.", ex);
}
if (envelope?.Items == null)
throw new InvalidDataException("Procurement API response is missing items.");
if (envelope.NextCursor is { Length: 0 }
|| envelope.NextCursor?.Length > options.MaxCursorLength)
{
throw new InvalidDataException("Procurement API response has an invalid cursor.");
}
ValidateItems(envelope.Items);
return new ProcurementPage<T>(envelope.Items, envelope.NextCursor);
}
}
}
private static void ValidateItems<T>(IEnumerable<T> items)
{
foreach (var item in items)
{
switch (item)
{
case ProcurementWorkOrder workOrder
when !IsNumericId(workOrder.WorkOrderId)
|| !ValidStatus(workOrder.WoStatus)
|| !ValidRecordType(workOrder.RecordType):
throw new InvalidDataException("Procurement API returned an invalid work order.");
case ProcurementWorkOrderComment comment
when !IsNumericId(comment.WorkOrderId)
|| string.IsNullOrWhiteSpace(comment.CommentId)
|| comment.CommentId.Length > 450:
throw new InvalidDataException("Procurement API returned an invalid comment.");
}
}
}
private static bool IsNumericId(string? value) =>
value is { Length: > 0 and <= 64 }
&& value.All(c => c is >= '0' and <= '9');
private static bool ValidStatus(string? value) =>
value == null
|| value is "new" or "assigned" or "in_progress" or "on_hold"
or "completed" or "cancelled" or "unknown";
private static bool ValidRecordType(string? value) =>
value == null
|| value is "new_work_order" or "update" or "comment" or "cancellation";
private async Task DelayBeforeRetryAsync(
int attempt,
WorkOrderReconciliationOptions options,
CancellationToken cancellationToken)
{
await Task.Delay(
TimeSpan.FromMilliseconds(
options.RetryBaseDelayMilliseconds * (1 << Math.Min(attempt, 8))),
_timeProvider,
cancellationToken);
}
private async Task SignAsync(
HttpRequestMessage request,
string path,
string canonicalQuery,
string region,
CancellationToken cancellationToken)
{
var credentials = await _credentials.GetAsync(cancellationToken);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var amzDate = now.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
var date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
const string payloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
request.Headers.TryAddWithoutValidation("X-Amz-Date", amzDate);
request.Headers.TryAddWithoutValidation("X-Amz-Content-Sha256", payloadHash);
var signedHeaders = "host;x-amz-content-sha256;x-amz-date";
var canonicalHeaders = $"host:{request.RequestUri!.Host}\n"
+ $"x-amz-content-sha256:{payloadHash}\n"
+ $"x-amz-date:{amzDate}\n";
if (credentials.UseToken)
{
request.Headers.TryAddWithoutValidation("X-Amz-Security-Token", credentials.Token);
signedHeaders += ";x-amz-security-token";
canonicalHeaders += $"x-amz-security-token:{credentials.Token}\n";
}
var canonicalRequest = $"GET\n{path}\n{canonicalQuery}\n"
+ $"{canonicalHeaders}\n{signedHeaders}\n{payloadHash}";
var scope = $"{date}/{region}/execute-api/aws4_request";
var stringToSign = "AWS4-HMAC-SHA256\n"
+ $"{amzDate}\n{scope}\n{Sha256Hex(canonicalRequest)}";
var signingKey = DeriveSigningKey(credentials.SecretKey, date, region, "execute-api");
var signature = Convert.ToHexString(
HMACSHA256.HashData(signingKey, Encoding.UTF8.GetBytes(stringToSign)))
.ToLowerInvariant();
CryptographicOperations.ZeroMemory(signingKey);
request.Headers.TryAddWithoutValidation(
"Authorization",
$"AWS4-HMAC-SHA256 Credential={credentials.AccessKey}/{scope}, "
+ $"SignedHeaders={signedHeaders}, Signature={signature}");
}
private static Uri ValidateOrigin(string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)
|| uri.Scheme != Uri.UriSchemeHttps
|| !string.Equals(
uri.Host,
"procurement-api.seahaven.com",
StringComparison.OrdinalIgnoreCase)
|| !uri.IsDefaultPort
|| uri.AbsolutePath != "/"
|| uri.Query.Length > 0
|| uri.UserInfo.Length > 0)
{
throw new InvalidOperationException("Procurement API base URL is not the allowed origin.");
}
return uri;
}
private static async Task<byte[]> ReadBoundedAsync(
HttpContent content,
int maximumBytes,
CancellationToken cancellationToken)
{
if (content.Headers.ContentLength > maximumBytes)
throw new InvalidDataException("Procurement API response is too large.");
await using var source = await content.ReadAsStreamAsync(cancellationToken);
using var destination = new MemoryStream();
var buffer = new byte[81920];
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken);
if (read == 0)
return destination.ToArray();
if (destination.Length + read > maximumBytes)
throw new InvalidDataException("Procurement API response is too large.");
destination.Write(buffer, 0, read);
}
}
private static byte[] DeriveSigningKey(
string secret,
string date,
string region,
string service)
{
var dateKey = HMACSHA256.HashData(
Encoding.UTF8.GetBytes($"AWS4{secret}"),
Encoding.UTF8.GetBytes(date));
var regionKey = HMACSHA256.HashData(dateKey, Encoding.UTF8.GetBytes(region));
CryptographicOperations.ZeroMemory(dateKey);
var serviceKey = HMACSHA256.HashData(regionKey, Encoding.UTF8.GetBytes(service));
CryptographicOperations.ZeroMemory(regionKey);
var signingKey = HMACSHA256.HashData(serviceKey, Encoding.UTF8.GetBytes("aws4_request"));
CryptographicOperations.ZeroMemory(serviceKey);
return signingKey;
}
private static string Sha256Hex(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string Encode(string value) =>
Uri.EscapeDataString(value).Replace("%7E", "~", StringComparison.Ordinal);
private sealed class PageEnvelope<T>
{
public List<T>? Items { get; set; }
[JsonPropertyName("next_cursor")]
public string? NextCursor { get; set; }
}
}
}

View file

@ -67,9 +67,30 @@ builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.Se
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorDocumentStoragePort, Api.SeaHavenIndustries.Infrastructure.VendorDocumentStorageAdapter>();
builder.Services.AddSingleton<Amazon.SecretsManager.IAmazonSecretsManager>(_ =>
{
var region = builder.Configuration[$"{SeaHaven.Services.Configuration.WorkOrderWebhookOptions.SectionName}:Region"];
return string.IsNullOrWhiteSpace(region)
? new Amazon.SecretsManager.AmazonSecretsManagerClient()
: new Amazon.SecretsManager.AmazonSecretsManagerClient(Amazon.RegionEndpoint.GetBySystemName(region));
});
builder.Services.AddSingleton<
SeaHaven.Services.Interfaces.IWorkOrderWebhookSecretProvider,
Api.SeaHavenIndustries.Infrastructure.AwsWorkOrderWebhookSecretProvider>();
builder.Services.AddSingleton<
Api.SeaHavenIndustries.Infrastructure.IProcurementAwsCredentialsProvider,
Api.SeaHavenIndustries.Infrastructure.DefaultProcurementAwsCredentialsProvider>();
builder.Services.AddHttpClient<
SeaHaven.Services.Interfaces.IProcurementWorkOrderClient,
Api.SeaHavenIndustries.Infrastructure.ProcurementWorkOrderClient>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AllowAutoRedirect = false
});
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
builder.Services.AddHostedService<WorkOrderReconciliationHostedService>();
builder.Services.AddOptions<WorkOrderIngestOptions>()
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
.Validate(

View file

@ -39,6 +39,30 @@
"Enabled": false,
"ApiKey": "${WORKORDER_INGEST_API_KEY}"
},
"WorkOrderWebhook": {
"Enabled": false,
"MaxBodyBytes": 1048576,
"AllowedClockSkewSeconds": 300,
"SecretCacheSeconds": 300,
"Region": "",
"SecretId": "workorder-ingest/shoc-webhook-hmac"
},
"WorkOrderReconciliation": {
"Enabled": false,
"RunOnStartup": true,
"BaseUrl": "https://procurement-api.seahaven.com",
"Region": "us-east-1",
"PageSize": 100,
"MaxPages": 10000,
"MaxCursorLength": 4096,
"RequestTimeoutSeconds": 30,
"MaxRetries": 3,
"RetryBaseDelayMilliseconds": 100,
"MaxResponseBytes": 4194304,
"PollSeconds": 10,
"ScheduleMinutes": 60,
"LeaseSeconds": 180
},
"Sync": {
"Enabled": true
},

View file

@ -49,6 +49,8 @@ namespace Data.SeaHavenIndustries
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
.IsRowVersion();
if (Database.IsSqlServer())
builder.HasSequence<long>("WorkOrderInternalNumberSequence");
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
@ -73,6 +75,17 @@ namespace Data.SeaHavenIndustries
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
// These indexes already exist in the migration history. Keep them in
// the runtime model so future additive migrations do not drop them.
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.AssignTo, w.ScheduledDate });
builder.Entity<WorkOrder>()
.HasIndex(w => w.ScheduledDate);
builder.Entity<WorkOrder>()
.HasIndex(w => w.WorkOrderType);
builder.Entity<WorkOrder>()
.Property(w => w.ExternalWorkOrderId)
.HasMaxLength(450);
@ -82,6 +95,36 @@ namespace Data.SeaHavenIndustries
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
builder.Entity<WorkOrderWebhookDelivery>()
.HasIndex(d => d.DeliveryId)
.IsUnique();
builder.Entity<Comments>()
.Property(c => c.ExternalSource)
.HasMaxLength(128);
builder.Entity<Comments>()
.Property(c => c.ExternalVersionHash)
.HasMaxLength(64);
builder.Entity<Comments>()
.Property(c => c.ExternalSourceEmailS3Key)
.HasMaxLength(2048);
builder.Entity<Locations>()
.Property(l => l.ExternalSource)
.HasMaxLength(128);
builder.Entity<Locations>()
.Property(l => l.ExternalLocationId)
.HasMaxLength(450);
builder.Entity<WorkOrderExternalReceipt>()
.HasIndex(r => new { r.Source, r.Kind, r.ExternalId })
.IsUnique()
.HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId");
builder.Entity<WorkOrderReconciliationJob>()
.HasIndex(j => j.State);
builder.Entity<WorkOrderReconciliationJob>()
.Property(j => j.Id)
.ValueGeneratedNever();
builder.Entity<Dispatch>()
.Property(d => d.RowVersion)
@ -97,6 +140,10 @@ namespace Data.SeaHavenIndustries
.HasIndex(c => c.NormalizedName)
.IsUnique();
builder.Entity<VendorCompany>()
.Property(c => c.RowVersion)
.IsRowVersion();
builder.Entity<Vendor>()
.HasOne(v => v.Company)
.WithMany(c => c.Vendors)
@ -129,6 +176,9 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
public DbSet<WorkOrderWebhookDelivery> WorkOrderWebhookDeliveries { get; set; }
public DbSet<WorkOrderReconciliationJob> WorkOrderReconciliationJobs { get; set; }
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
@ -190,6 +240,18 @@ namespace Data.SeaHavenIndustries
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
foreach (var entry in ChangeTracker.Entries<WorkOrderReconciliationJob>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
foreach (var entry in ChangeTracker.Entries<VendorCompany>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
}
private static byte[] IncrementRowVersion(byte[]? current)

View file

@ -1,3 +1,5 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260713120000_Phase7_ExternalWorkOrderIdUnique")]
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
{
/// <inheritdoc />

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH133_WorkOrderWebhookIngestion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ExternalLastOccurredAt",
table: "workOrders",
type: "datetimeoffset",
nullable: true);
migrationBuilder.CreateTable(
name: "WorkOrderWebhookDeliveries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DeliveryId = table.Column<string>(
type: "nvarchar(128)",
maxLength: 128,
nullable: false),
EventType = table.Column<string>(
type: "nvarchar(64)",
maxLength: 64,
nullable: false),
OccurredAt = table.Column<DateTimeOffset>(
type: "datetimeoffset",
nullable: false),
ProcessedAt = table.Column<DateTimeOffset>(
type: "datetimeoffset",
nullable: false),
BodySha256 = table.Column<string>(
type: "nvarchar(64)",
maxLength: 64,
nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkOrderWebhookDeliveries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WorkOrderWebhookDeliveries_DeliveryId",
table: "WorkOrderWebhookDeliveries",
column: "DeliveryId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkOrderWebhookDeliveries");
migrationBuilder.DropColumn(
name: "ExternalLastOccurredAt",
table: "workOrders");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH133_WorkOrderReconciliation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DECLARE @nextValue bigint =
COALESCE((
SELECT MAX(TRY_CONVERT(bigint, [InternalWONumber])) + 1
FROM [workOrders]
WHERE [InternalWONumber] IS NOT NULL
), 1);
IF NOT EXISTS (
SELECT 1 FROM sys.sequences
WHERE [name] = N'WorkOrderInternalNumberSequence'
AND [schema_id] = SCHEMA_ID(N'dbo')
)
BEGIN
DECLARE @sql nvarchar(max) =
N'CREATE SEQUENCE [dbo].[WorkOrderInternalNumberSequence] AS bigint START WITH '
+ CONVERT(nvarchar(20), @nextValue)
+ N' INCREMENT BY 1;';
EXEC sp_executesql @sql;
END
""");
migrationBuilder.AddColumn<string>(
name: "ExternalAssignedTo",
table: "workOrders",
type: "nvarchar(450)",
maxLength: 450,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalRecordType",
table: "workOrders",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalSource",
table: "workOrders",
type: "nvarchar(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ExternalUpdatedAt",
table: "workOrders",
type: "datetimeoffset",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalVersionHash",
table: "workOrders",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalLocationId",
table: "Locations",
type: "nvarchar(450)",
maxLength: 450,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalSource",
table: "Locations",
type: "nvarchar(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalSource",
table: "Comments",
type: "nvarchar(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalSourceEmailS3Key",
table: "Comments",
type: "nvarchar(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ExternalUpdatedAt",
table: "Comments",
type: "datetimeoffset",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalVersionHash",
table: "Comments",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateTable(
name: "WorkOrderExternalReceipts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Source = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ExternalId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
VersionHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ProcessedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkOrderExternalReceipts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "WorkOrderReconciliationJobs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
RunId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FenceToken = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
State = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Reason = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
RequestedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LeaseExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
WorkOrdersProcessed = table.Column<int>(type: "int", nullable: false),
CommentsProcessed = table.Column<int>(type: "int", nullable: false),
ErrorCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkOrderReconciliationJobs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WorkOrderExternalReceipts_Source_Kind_ExternalId",
table: "WorkOrderExternalReceipts",
columns: new[] { "Source", "Kind", "ExternalId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WorkOrderReconciliationJobs_State",
table: "WorkOrderReconciliationJobs",
column: "State");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkOrderExternalReceipts");
migrationBuilder.DropTable(
name: "WorkOrderReconciliationJobs");
migrationBuilder.DropColumn(
name: "ExternalAssignedTo",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExternalRecordType",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExternalSource",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExternalUpdatedAt",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExternalVersionHash",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExternalLocationId",
table: "Locations");
migrationBuilder.DropColumn(
name: "ExternalSource",
table: "Locations");
migrationBuilder.DropColumn(
name: "ExternalSource",
table: "Comments");
migrationBuilder.DropColumn(
name: "ExternalSourceEmailS3Key",
table: "Comments");
migrationBuilder.DropColumn(
name: "ExternalUpdatedAt",
table: "Comments");
migrationBuilder.DropColumn(
name: "ExternalVersionHash",
table: "Comments");
migrationBuilder.DropSequence(
name: "WorkOrderInternalNumberSequence");
}
}
}

View file

@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH198_VendorCompanyNotesAndRowVersion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "VendorCompanies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "VendorCompanies",
type: "rowversion",
rowVersion: true,
nullable: true);
// Backfill VendorCompany.Notes (vendor-directory source) deterministically
// from the most recently modified then highest-id nonempty Vendor.Notes.
// Vendor.Notes is retained separately for work-order editor compatibility;
// roster/directory paths read VendorCompany.Notes.
migrationBuilder.Sql(@"
;WITH SourceNotes AS (
SELECT
v.[CompanyId],
v.[Notes],
ROW_NUMBER() OVER (
PARTITION BY v.[CompanyId]
ORDER BY
v.[LastModificationTime] DESC,
v.[Id] DESC
) AS [RowNumber]
FROM [Vendors] v
WHERE v.[CompanyId] IS NOT NULL
AND v.[Notes] IS NOT NULL
AND LTRIM(RTRIM(v.[Notes])) <> ''
)
UPDATE vc
SET vc.[Notes] = src.[Notes]
FROM [VendorCompanies] vc
INNER JOIN SourceNotes src
ON src.[CompanyId] = vc.[Id]
WHERE src.[RowNumber] = 1;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Notes",
table: "VendorCompanies");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "VendorCompanies");
}
}
}

View file

@ -22,6 +22,8 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.HasSequence("WorkOrderInternalNumberSequence");
modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b =>
{
b.Property<int>("Id")
@ -107,7 +109,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Accounts", (string)null);
b.ToTable("Accounts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
@ -175,7 +177,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("Addresses", (string)null);
b.ToTable("Addresses");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
@ -334,7 +336,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Assets", (string)null);
b.ToTable("Assets");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
@ -371,7 +373,89 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Categories", (string)null);
b.ToTable("Categories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CommentType")
.HasColumnType("nvarchar(max)");
b.Property<string>("Commenter")
.HasColumnType("nvarchar(max)");
b.Property<string>("Commenttext")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<int?>("DispatchId")
.HasColumnType("int");
b.Property<string>("Documents")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExternalCommentId")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExternalSource")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ExternalSourceEmailS3Key")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<DateTimeOffset?>("ExternalUpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("ExternalVersionHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("RecordType")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<int?>("WorkerOrderId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("DispatchId");
b.HasIndex("UserId");
b.HasIndex("WorkerOrderId");
b.ToTable("Comments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
@ -423,77 +507,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("ServiceKey", "IsActive")
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
b.HasIndex("ServiceKey", "IsActive");
b.ToTable("CompletionDocTemplates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CommentType")
.HasColumnType("nvarchar(max)");
b.Property<string>("Commenter")
.HasColumnType("nvarchar(max)");
b.Property<string>("Commenttext")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<int?>("DispatchId")
.HasColumnType("int");
b.Property<string>("Documents")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExternalCommentId")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("RecordType")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<int?>("WorkerOrderId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("DispatchId");
b.HasIndex("UserId");
b.HasIndex("WorkerOrderId");
b.ToTable("Comments", (string)null);
b.ToTable("CompletionDocTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
@ -559,7 +575,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("ContactDetails", (string)null);
b.ToTable("ContactDetails");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
@ -651,7 +667,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Contacts", (string)null);
b.ToTable("Contacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
@ -669,7 +685,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Departments", (string)null);
b.ToTable("Departments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
@ -788,7 +804,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("Dispatches", (string)null);
b.ToTable("Dispatches");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
@ -847,7 +863,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchChecklistItems", (string)null);
b.ToTable("DispatchChecklistItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
@ -901,7 +917,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("DispatchId");
b.ToTable("DispatchSignoffs", (string)null);
b.ToTable("DispatchSignoffs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
@ -971,7 +987,7 @@ namespace Data.SeaHavenIndustries.Migrations
.IsUnique()
.HasFilter("[Status] = 'Pending'");
b.ToTable("DispatchUpliftRequests", (string)null);
b.ToTable("DispatchUpliftRequests");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
@ -994,7 +1010,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders", (string)null);
b.ToTable("DispatchWorkOrders");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
@ -1024,7 +1040,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("DropdownOptions", (string)null);
b.ToTable("DropdownOptions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
@ -1129,7 +1145,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("RegionId");
b.ToTable("Employees", (string)null);
b.ToTable("Employees");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
@ -1169,7 +1185,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId")
.IsUnique();
b.ToTable("EmployeeAddresses", (string)null);
b.ToTable("EmployeeAddresses");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
@ -1197,7 +1213,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeeEmails", (string)null);
b.ToTable("EmployeeEmails");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
@ -1227,7 +1243,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeePhones", (string)null);
b.ToTable("EmployeePhones");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
@ -1375,7 +1391,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("FollowUps", (string)null);
b.ToTable("FollowUps");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
@ -1400,7 +1416,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("ForgetPasswordCodes", (string)null);
b.ToTable("ForgetPasswordCodes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
@ -1418,7 +1434,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("JobTitles", (string)null);
b.ToTable("JobTitles");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
@ -1450,6 +1466,14 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExternalLocationId")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("ExternalSource")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1488,7 +1512,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Locations", (string)null);
b.ToTable("Locations");
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
@ -1546,7 +1570,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("PMSchedules", (string)null);
b.ToTable("PMSchedules");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
@ -1620,7 +1644,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("Quotes", (string)null);
b.ToTable("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
@ -1668,7 +1692,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("QuoteId");
b.ToTable("QuotesLines", (string)null);
b.ToTable("QuotesLines");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
@ -1686,7 +1710,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Regions", (string)null);
b.ToTable("Regions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
@ -1788,7 +1812,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("TaskListTemplates", (string)null);
b.ToTable("TaskListTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
@ -1833,7 +1857,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("TaskListTemplateId");
b.ToTable("TaskListTemplateItems", (string)null);
b.ToTable("TaskListTemplateItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
@ -1935,7 +1959,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Templates", (string)null);
b.ToTable("Templates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
@ -2023,7 +2047,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("CompanyId");
b.ToTable("Vendors", (string)null);
b.ToTable("Vendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
@ -2082,7 +2106,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("VendorId");
b.ToTable("VendorAccessTokens", (string)null);
b.ToTable("VendorAccessTokens");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
@ -2170,6 +2194,14 @@ namespace Data.SeaHavenIndustries.Migrations
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@ -2339,6 +2371,28 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DueDate")
.HasColumnType("datetime2");
b.Property<string>("ExternalAssignedTo")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset?>("ExternalLastOccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("ExternalRecordType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ExternalSource")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTimeOffset?>("ExternalUpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("ExternalVersionHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ExternalWorkOrderId")
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
@ -2499,10 +2553,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("AssignTo");
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("ExternalWorkOrderId")
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
@ -2510,9 +2560,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("InternalWONumber")
.HasFilter("[istemplate] = 0");
b.HasIndex("LifecycleStatus", "ScheduledDate")
.HasFilter("[istemplate] = 0");
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
@ -2524,7 +2571,12 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderType");
b.ToTable("workOrders", (string)null);
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("LifecycleStatus", "ScheduledDate")
.HasFilter("[istemplate] = 0");
b.ToTable("workOrders");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -2569,7 +2621,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderAttachments", (string)null);
b.ToTable("workOrderAttachments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@ -2619,7 +2671,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("WorkOrderAuditLogs", (string)null);
b.ToTable("WorkOrderAuditLogs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@ -2663,7 +2715,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderCategories", (string)null);
b.ToTable("workOrderCategories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@ -2710,7 +2762,50 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("WorkOrderContacts", (string)null);
b.ToTable("WorkOrderContacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTimeOffset>("ProcessedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("VersionHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Source", "Kind", "ExternalId")
.IsUnique()
.HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId");
b.ToTable("WorkOrderExternalReceipts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@ -2739,10 +2834,64 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks", (string)null);
b.ToTable("WorkOrderFieldLocks");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<int>("CommentsProcessed")
.HasColumnType("int");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("ErrorCode")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid?>("FenceToken")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("LeaseExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Reason")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTimeOffset?>("RequestedAt")
.HasColumnType("datetimeoffset");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<Guid>("RunId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("WorkOrdersProcessed")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("State");
b.ToTable("WorkOrderReconciliationJobs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -2750,25 +2899,33 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
b.Property<string>("BodySha256")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("DeliveryId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("ProcessedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
b.HasIndex("DeliveryId")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
b.ToTable("WorkOrderWebhookDeliveries");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
@ -3433,10 +3590,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.WithMany("FieldLocks")
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
@ -3444,10 +3601,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany("FieldLocks")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();

View file

@ -11,6 +11,10 @@ namespace Data.SeaHavenIndustries
public string? RecordType { get; set; }
public string? CommentType { get; set; }
public string? ExternalCommentId { get; set; }
public string? ExternalSource { get; set; }
public DateTimeOffset? ExternalUpdatedAt { get; set; }
public string? ExternalVersionHash { get; set; }
public string? ExternalSourceEmailS3Key { get; set; }
public string? Documents { get; set; }
public string? UserId { get; set; }
[ForeignKey(nameof(UserId))]

View file

@ -23,6 +23,8 @@ namespace Data.SeaHavenIndustries
public string? PhoneNumber { get; set; }
public string? Email { get; set; }
public string? Status { get; set; }
public string? ExternalSource { get; set; }
public string? ExternalLocationId { get; set; }
// Navigation Properties
public ICollection<Template>? Templates { get; set; }

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
@ -13,6 +14,10 @@ namespace Data.SeaHavenIndustries
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }
public ICollection<Vendor>? Vendors { get; set; }
}

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace Data.SeaHavenIndustries
{
public sealed class WorkOrderExternalReceipt
{
public long Id { get; set; }
[MaxLength(128)]
public required string Source { get; set; }
[MaxLength(32)]
public required string Kind { get; set; }
[MaxLength(450)]
public required string ExternalId { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
[MaxLength(64)]
public required string VersionHash { get; set; }
public DateTimeOffset ProcessedAt { get; set; }
}
}

View file

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace Data.SeaHavenIndustries
{
public sealed class WorkOrderReconciliationJob
{
public int Id { get; set; }
public Guid RunId { get; set; }
public Guid? FenceToken { get; set; }
[MaxLength(32)]
public string State { get; set; } = "Idle";
[MaxLength(128)]
public string? Reason { get; set; }
public DateTimeOffset? RequestedAt { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public int WorkOrdersProcessed { get; set; }
public int CommentsProcessed { get; set; }
[MaxLength(64)]
public string? ErrorCode { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }
}
}

View file

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Data.SeaHavenIndustries
{
public sealed class WorkOrderWebhookDelivery
{
public int Id { get; set; }
[MaxLength(128)]
public string DeliveryId { get; set; } = "";
[MaxLength(64)]
public string EventType { get; set; } = "";
public DateTimeOffset OccurredAt { get; set; }
public DateTimeOffset ProcessedAt { get; set; }
[MaxLength(64)]
public string BodySha256 { get; set; } = "";
}
}

View file

@ -9,6 +9,16 @@ namespace Data.SeaHavenIndustries
// --- Core slice ---
public string? InternalWONumber { get; set; }
public string? ExternalWorkOrderId { get; set; }
public DateTimeOffset? ExternalLastOccurredAt { get; set; }
[MaxLength(128)]
public string? ExternalSource { get; set; }
public DateTimeOffset? ExternalUpdatedAt { get; set; }
[MaxLength(64)]
public string? ExternalVersionHash { get; set; }
[MaxLength(450)]
public string? ExternalAssignedTo { get; set; }
[MaxLength(64)]
public string? ExternalRecordType { get; set; }
public string? WorkerOrderNumber { get; set; }
public string? WorkerOrderTitle { get; set; }
public string? Description { get; set; }

View file

@ -64,8 +64,8 @@ namespace SeaHaven.DataServices.Helpers
if (overdue && !hasTypes)
{
return query.Where(w =>
w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
w.DueDate != null
&& w.DueDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
}
@ -73,8 +73,8 @@ namespace SeaHaven.DataServices.Helpers
if (overdue)
{
return query.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
(w.DueDate != null
&& w.DueDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)));

View file

@ -60,8 +60,8 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|| ("overdue".Contains(s)
&& w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
&& w.DueDate != null
&& w.DueDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))

View file

@ -0,0 +1,320 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Implementation
{
public class VendorCompanyRosterDataService : IVendorCompanyRosterDataService
{
private static readonly string[] TerminalLegacyStatuses = { "completed", "cancelled", "canceled" };
private readonly ApplicationDbContext _context;
public VendorCompanyRosterDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
int? vendorId,
int? companyId,
CancellationToken cancellationToken)
{
int? resolvedCompanyId = companyId;
if (vendorId.HasValue)
{
var vendorCompanyId = await _context.Vendors
.AsNoTracking()
.Where(v => v.Id == vendorId.Value && (v.IsDeleted == null || v.IsDeleted == false))
.Select(v => v.CompanyId)
.FirstOrDefaultAsync(cancellationToken);
if (!vendorCompanyId.HasValue ||
(companyId.HasValue && vendorCompanyId.Value != companyId.Value))
return null;
resolvedCompanyId = vendorCompanyId;
}
if (!resolvedCompanyId.HasValue)
return null;
var company = await _context.VendorCompanies
.AsNoTracking()
.Where(c => c.Id == resolvedCompanyId.Value)
.Select(c => new VendorCompanyRosterReadModel
{
CompanyId = c.Id,
Name = c.Name,
CompanyPhone = c.CompanyPhone,
Email = c.Email,
Address = c.Address,
City = c.City,
State = c.State,
Zip = c.Zip,
GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes,
RowVersion = c.RowVersion
})
.FirstOrDefaultAsync(cancellationToken);
if (company == null)
return null;
var technicians = await _context.Vendors
.AsNoTracking()
.Where(v => v.CompanyId == company.CompanyId
&& (v.IsDeleted == null || v.IsDeleted == false))
.OrderBy(v => v.Id)
.Select(v => new VendorRosterTechnicianReadModel
{
Id = v.Id,
ContactName = v.ContactName,
Phone = v.Phone,
Email = v.Email,
PreferredContact = v.PreferredContact,
TradeSpecialties = v.TradeSpecialties,
IsActive = v.IsActive,
TotalJobs = v.TotalJobs
})
.ToListAsync(cancellationToken);
company.Technicians = technicians;
return company;
}
public async Task<VendorCompanyRosterReadModel> SaveRosterAsync(
VendorCompanyRosterWriteModel roster,
CancellationToken cancellationToken)
{
var company = await _context.VendorCompanies
.FirstOrDefaultAsync(c => c.Id == roster.CompanyId, cancellationToken);
if (company == null)
throw new KeyNotFoundException($"Vendor company with ID {roster.CompanyId} not found.");
// Concurrency: pin the client-supplied row version as the original value so a
// stale save throws DbUpdateConcurrencyException (mapped to a stable 409).
if (roster.RowVersion != null && roster.RowVersion.Length > 0)
_context.Entry(company).Property(c => c.RowVersion).OriginalValue = roster.RowVersion;
var now = DateTime.UtcNow;
var trimmedName = roster.Name.Trim();
company.Name = trimmedName;
company.NormalizedName = trimmedName.ToLowerInvariant();
company.CompanyPhone = roster.CompanyPhone;
company.Email = roster.Email;
company.Address = roster.Address;
company.City = roster.City;
company.State = roster.State;
company.Zip = roster.Zip;
company.GoogleMapsUrl = roster.GoogleMapsUrl;
company.Notes = roster.Notes;
company.LastModificationTime = now;
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
if (actorId.HasValue)
company.LastModifierUserId = actorId;
var existingTechnicians = await _context.Vendors
.Where(v => v.CompanyId == company.Id && (v.IsDeleted == null || v.IsDeleted == false))
.ToListAsync(cancellationToken);
var snapshotIds = roster.Technicians
.Where(t => t.Id.HasValue)
.Select(t => t.Id!.Value)
.ToHashSet();
var toRemove = existingTechnicians
.Where(e => !snapshotIds.Contains(e.Id))
.ToList();
// A technician omitted from the snapshot may only be soft-deleted when it has no
// open linked work orders; otherwise the whole reconcile fails (nothing saved).
if (toRemove.Count > 0)
{
var blocked = await GetOpenLinkedWorkOrdersAsync(
toRemove.Select(vendor => vendor.Id).ToArray(),
cancellationToken);
if (blocked.Count > 0)
throw new VendorRosterConflictException(
"One or more technicians cannot be removed because they have open linked work orders.",
blocked);
}
foreach (var removed in toRemove)
{
removed.IsActive = false;
removed.IsDeleted = true;
removed.DeletionTime = now;
removed.LastModificationTime = now;
if (actorId.HasValue)
removed.LastModifierUserId = actorId;
}
foreach (var technician in roster.Technicians)
{
if (technician.Id.HasValue)
{
var existing = existingTechnicians.FirstOrDefault(e => e.Id == technician.Id.Value);
if (existing == null)
continue;
existing.ContactName = technician.ContactName;
existing.Phone = technician.Phone;
existing.Email = technician.Email;
existing.PreferredContact = technician.PreferredContact;
existing.TradeSpecialties = technician.TradeSpecialties;
existing.IsActive = technician.IsActive;
existing.CompanyName = trimmedName;
existing.CompanyPhone = company.CompanyPhone;
existing.Address = company.Address;
existing.City = company.City;
existing.State = company.State;
existing.Zip = company.Zip;
existing.GoogleMapsUrl = company.GoogleMapsUrl;
existing.LastModificationTime = now;
if (actorId.HasValue)
existing.LastModifierUserId = actorId;
}
else
{
var vendor = new Vendor
{
CompanyName = trimmedName,
ContactName = technician.ContactName,
Phone = technician.Phone,
Email = technician.Email,
PreferredContact = technician.PreferredContact,
TradeSpecialties = technician.TradeSpecialties,
IsActive = technician.IsActive,
CompanyId = company.Id,
CompanyPhone = company.CompanyPhone,
Address = company.Address,
City = company.City,
State = company.State,
Zip = company.Zip,
GoogleMapsUrl = company.GoogleMapsUrl,
CreatedDate = now,
createdby = roster.ActorUserId
};
await _context.Vendors.AddAsync(vendor, cancellationToken);
}
}
await _context.SaveChangesAsync(cancellationToken);
return await GetRosterAsync(null, company.Id, cancellationToken)
?? throw new InvalidOperationException("Roster could not be reloaded after save.");
}
public async Task<VendorCompanyRosterReadModel> CreateRosterAsync(
VendorCompanyRosterWriteModel roster,
CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var trimmedName = roster.Name.Trim();
var company = new VendorCompany
{
Name = trimmedName,
NormalizedName = trimmedName.ToLowerInvariant(),
CompanyPhone = roster.CompanyPhone,
Email = roster.Email,
Address = roster.Address,
City = roster.City,
State = roster.State,
Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes,
CreatedDate = now,
createdby = roster.ActorUserId
};
await _context.VendorCompanies.AddAsync(company, cancellationToken);
foreach (var technician in roster.Technicians)
{
var vendor = new Vendor
{
CompanyName = trimmedName,
ContactName = technician.ContactName,
Phone = technician.Phone,
Email = technician.Email,
PreferredContact = technician.PreferredContact,
TradeSpecialties = technician.TradeSpecialties,
IsActive = technician.IsActive,
Company = company,
CompanyPhone = company.CompanyPhone,
Address = company.Address,
City = company.City,
State = company.State,
Zip = company.Zip,
GoogleMapsUrl = company.GoogleMapsUrl,
CreatedDate = now,
createdby = roster.ActorUserId
};
await _context.Vendors.AddAsync(vendor, cancellationToken);
}
await _context.SaveChangesAsync(cancellationToken);
return await GetRosterAsync(null, company.Id, cancellationToken)
?? throw new InvalidOperationException("Roster could not be reloaded after create.");
}
private async Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(
IReadOnlyCollection<int> vendorIds,
CancellationToken cancellationToken)
{
var directLinks = _context.Dispatches
.Where(d => vendorIds.Contains(d.VendorId) && d.WorkOrderId.HasValue)
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value });
var multiLinks = _context.DispatchWorkOrders
.Where(link => link.Dispatch != null && vendorIds.Contains(link.Dispatch.VendorId))
.Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId });
var linkedIds = await directLinks
.Concat(multiLinks)
.Join(_context.workOrders,
link => link.WorkOrderId,
wo => wo.Id,
(link, wo) => new LinkedWorkOrderInfo
{
WorkOrderId = wo.Id,
WorkOrderNumber = wo.WorkerOrderNumber,
WorkOrderTitle = wo.WorkerOrderTitle,
Status = wo.Status,
LifecycleStatus = wo.LifecycleStatus,
ScheduledDate = wo.ScheduledDate,
DispatchId = link.DispatchId
})
.OrderBy(wo => wo.WorkOrderId)
.ToListAsync(cancellationToken);
return linkedIds
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
.DistinctBy(x => x.WorkOrderId)
.ToList();
}
private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus)
{
if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled)
return true;
if (!string.IsNullOrWhiteSpace(legacyStatus) &&
TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant()))
return true;
return false;
}
}
}

View file

@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using System.Reflection;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Implementation
{
@ -79,6 +80,39 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(cancellationToken);
}
internal IQueryable<VendorCompany> BuildCompaniesForFacetsQuery(bool? isActive)
{
var query = _context.VendorCompanies
.AsNoTracking();
if (isActive == true)
{
// Active: companies with zero non-deleted technicians (company-only rows)
// or at least one active non-deleted technician.
query = query.Where(c =>
!_context.Vendors.Any(v =>
v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false))
|| _context.Vendors.Any(v =>
v.CompanyId == c.Id
&& (v.IsDeleted == null || v.IsDeleted == false)
&& v.IsActive));
}
else if (isActive == false)
{
// Inactive: companies with at least one inactive non-deleted technician.
query = query.Where(c =>
_context.Vendors.Any(v =>
v.CompanyId == c.Id
&& (v.IsDeleted == null || v.IsDeleted == false)
&& !v.IsActive));
}
return query.OrderBy(c => c.Id);
}
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
@ -107,6 +141,196 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
public async Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
CancellationToken cancellationToken = default)
{
var filtered = BuildDirectoryUnionQuery(
search,
isActive,
companies,
trades,
locations,
jobBuckets);
var totalCount = await filtered.CountAsync(cancellationToken);
var items = await filtered
.OrderByDescending(row => row.VendorId.HasValue)
.ThenByDescending(row => row.VendorId ?? 0)
.ThenByDescending(row => row.CompanyId)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, totalCount);
}
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null)
{
// Arm A: existing per-technician rows (every non-deleted vendor, including
// those with no company). Notes come from the linked VendorCompany.
var perTechnician = _context.Vendors
.AsNoTracking()
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
if (isActive.HasValue)
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
var armA = perTechnician.Select(v => new VendorDirectoryRow
{
VendorId = v.Id,
CompanyId = v.CompanyId ?? 0,
CompanyName = v.CompanyName,
ContactName = v.ContactName,
Email = v.Email,
Phone = v.Phone,
CompanyPhone = v.CompanyPhone,
PreferredContact = v.PreferredContact,
Address = v.Address,
City = v.City,
State = v.State,
Zip = v.Zip,
TradeSpecialties = v.TradeSpecialties,
GoogleMapsUrl = v.GoogleMapsUrl,
Notes = v.Company != null ? v.Company.Notes : null,
IsActive = v.IsActive,
TotalJobs = _context.Dispatches
.Where(dispatch =>
dispatch.VendorId == v.Id &&
dispatch.WorkOrderId.HasValue)
.Select(dispatch => dispatch.WorkOrderId!.Value)
.Union(
_context.DispatchWorkOrders
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
.Select(link => link.WorkOrderId))
.Count()
});
// Arm B: company-only rows for companies with zero non-deleted technicians.
// These carry a null VendorId and a populated CompanyId. A company has no
// active/inactive flag of its own, so it is shown unless the caller explicitly
// asks for inactive vendors only.
var includeCompanyRows = !isActive.HasValue || isActive.Value;
var union = armA;
if (includeCompanyRows)
{
var armB = _context.VendorCompanies
.AsNoTracking()
.Where(c => !_context.Vendors
.Any(v => v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false)))
.Select(c => new VendorDirectoryRow
{
VendorId = (int?)null,
CompanyId = c.Id,
CompanyName = c.Name,
ContactName = null,
Email = c.Email,
Phone = null,
CompanyPhone = c.CompanyPhone,
PreferredContact = null,
Address = c.Address,
City = c.City,
State = c.State,
Zip = c.Zip,
TradeSpecialties = null,
GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes,
IsActive = true,
TotalJobs = 0
});
union = armA.Concat(armB);
}
return ApplyDirectoryRowFilters(
union,
search,
companies,
trades,
locations,
jobBuckets);
}
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
IQueryable<VendorDirectoryRow> query,
string? search,
IReadOnlyCollection<string>? companies,
IReadOnlyCollection<string>? trades,
IReadOnlyCollection<string>? locations,
IReadOnlyCollection<string>? jobBuckets)
{
if (!string.IsNullOrWhiteSpace(search))
{
var normalizedSearch = search.ToLower();
query = query.Where(row =>
(row.CompanyName ?? "").ToLower().Contains(normalizedSearch) ||
(row.ContactName ?? "").ToLower().Contains(normalizedSearch) ||
(row.Email ?? "").ToLower().Contains(normalizedSearch) ||
(row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) ||
(row.City ?? "").ToLower().Contains(normalizedSearch) ||
(row.State ?? "").ToLower().Contains(normalizedSearch) ||
(row.Zip ?? "").ToLower().Contains(normalizedSearch));
}
var selectedCompanies = companies?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedCompanies.Length > 0)
query = query.Where(row =>
selectedCompanies.Contains((row.CompanyName ?? "").ToLower()));
var selectedTrades = trades?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedTrades.Length > 0)
query = query.Where(BuildTradePredicate<VendorDirectoryRow>(selectedTrades));
var selectedLocations = locations?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedLocations.Length > 0)
query = query.Where(BuildLocationPredicate<VendorDirectoryRow>(selectedLocations));
var selectedJobBuckets = jobBuckets?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedJobBuckets.Length == 0)
return query;
var under50 = selectedJobBuckets.Contains("under-50");
var from50To99 = selectedJobBuckets.Contains("50-99");
var from100To149 = selectedJobBuckets.Contains("100-149");
var from150 = selectedJobBuckets.Contains("150-plus");
return query.Where(row =>
(under50 && row.TotalJobs < 50) ||
(from50To99 && row.TotalJobs >= 50 && row.TotalJobs <= 99) ||
(from100To149 && row.TotalJobs >= 100 && row.TotalJobs <= 149) ||
(from150 && row.TotalJobs >= 150));
}
internal IQueryable<Vendor> BuildDirectoryQuery(
string? search = null,
bool? isActive = true,
@ -150,7 +374,7 @@ namespace SeaHaven.DataServices.Implementation
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedTrades.Length > 0)
query = query.Where(BuildTradePredicate(selectedTrades));
query = query.Where(BuildTradePredicate<Vendor>(selectedTrades));
var selectedLocations = locations?
.Where(value => !string.IsNullOrWhiteSpace(value))
@ -158,7 +382,7 @@ namespace SeaHaven.DataServices.Implementation
.Distinct()
.ToArray() ?? Array.Empty<string>();
if (selectedLocations.Length > 0)
query = query.Where(BuildLocationPredicate(selectedLocations));
query = query.Where(BuildLocationPredicate<Vendor>(selectedLocations));
var result = ProjectWithTotalJobs(query);
var selectedJobBuckets = jobBuckets?
@ -216,10 +440,10 @@ namespace SeaHaven.DataServices.Implementation
private static readonly MethodInfo StringConcatMethod =
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
private static Expression<Func<Vendor, bool>> BuildTradePredicate(
private static Expression<Func<T, bool>> BuildTradePredicate<T>(
IReadOnlyCollection<string> selectedTrades)
{
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
var vendor = Expression.Parameter(typeof(T), "vendor");
var specialties = Expression.Coalesce(
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
Expression.Constant(string.Empty));
@ -247,13 +471,13 @@ namespace SeaHaven.DataServices.Implementation
}
}
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
}
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
IReadOnlyCollection<string> selectedLocations)
{
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
var vendor = Expression.Parameter(typeof(T), "vendor");
var label = BuildLocationLabelBody(vendor);
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
@ -261,7 +485,7 @@ namespace SeaHaven.DataServices.Implementation
foreach (var location in selectedLocations)
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
}
private static Expression BuildLocationLabelBody(ParameterExpression vendor)

View file

@ -30,6 +30,16 @@ namespace SeaHaven.DataServices.Implementation
&& (document.IsDeleted == null || document.IsDeleted == false), cancellationToken);
}
public Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken)
{
return _context.VendorCompletionDocuments
.Where(document => document.DispatchId == dispatchId
&& document.VendorId == vendorId
&& (document.IsDeleted == null || document.IsDeleted == false))
.OrderByDescending(document => document.Version)
.ToListAsync(cancellationToken);
}
public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken)
{
await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken);

View file

@ -120,7 +120,7 @@ namespace SeaHaven.DataServices.Implementation
var batch = await _context.workOrders
.Where(w => w.IsDeleted != true)
.Where(w => w.istemplate != true)
.Where(w => w.ScheduledDate != null)
.Where(w => w.DueDate != null)
.OrderBy(w => w.Id)
.Skip(skip)
.Take(batchSize)
@ -132,7 +132,7 @@ namespace SeaHaven.DataServices.Implementation
foreach (var workOrder in batch)
{
examinedCount++;
var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
var shouldBePastDue = workOrder.DueDate!.Value.Date < today
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);

View file

@ -0,0 +1,200 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public sealed class WorkOrderReconciliationDataService : IWorkOrderReconciliationDataService
{
private const int SingletonId = 1;
private readonly ApplicationDbContext _context;
public WorkOrderReconciliationDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<ReconciliationJobSnapshot> EnqueueAsync(
string reason,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var job = await GetOrCreateAsync(cancellationToken);
if (job.State is not ("Pending" or "Running"))
{
job.RunId = Guid.NewGuid();
job.FenceToken = null;
job.State = "Pending";
job.Reason = reason[..Math.Min(reason.Length, 128)];
job.RequestedAt = now;
job.StartedAt = null;
job.LeaseExpiresAt = null;
job.CompletedAt = null;
job.WorkOrdersProcessed = 0;
job.CommentsProcessed = 0;
job.ErrorCode = null;
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
_context.ChangeTracker.Clear();
job = await _context.WorkOrderReconciliationJobs
.SingleAsync(j => j.Id == SingletonId, cancellationToken);
}
}
return Snapshot(job);
}
public async Task<ReconciliationLease?> TryAcquirePendingAsync(
DateTimeOffset now,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var job = await GetOrCreateAsync(cancellationToken);
var leaseExpired = job.State == "Running"
&& job.LeaseExpiresAt.HasValue
&& job.LeaseExpiresAt <= now;
if (job.State != "Pending" && !leaseExpired)
return null;
job.State = "Running";
job.StartedAt ??= now;
job.LeaseExpiresAt = now.Add(leaseDuration);
job.FenceToken = Guid.NewGuid();
try
{
await _context.SaveChangesAsync(cancellationToken);
return new ReconciliationLease(job.RunId, job.FenceToken.Value);
}
catch (DbUpdateConcurrencyException)
{
_context.ChangeTracker.Clear();
return null;
}
}
public async Task<ReconciliationJobSnapshot> GetStatusAsync(
CancellationToken cancellationToken)
{
var job = await _context.WorkOrderReconciliationJobs
.AsNoTracking()
.SingleOrDefaultAsync(j => j.Id == SingletonId, cancellationToken);
return job == null
? new ReconciliationJobSnapshot(null, "Idle", null, null, null, null, 0, 0, null)
: Snapshot(job);
}
public async Task<bool> RenewLeaseAsync(
Guid runId,
Guid fenceToken,
DateTimeOffset now,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var job = await _context.WorkOrderReconciliationJobs.SingleOrDefaultAsync(
j => j.Id == SingletonId
&& j.RunId == runId
&& j.FenceToken == fenceToken
&& j.State == "Running",
cancellationToken);
if (job == null)
return false;
job.LeaseExpiresAt = now.Add(leaseDuration);
try
{
await _context.SaveChangesAsync(cancellationToken);
return true;
}
catch (DbUpdateConcurrencyException)
{
_context.ChangeTracker.Clear();
return false;
}
}
public Task CompleteAsync(
Guid runId,
Guid fenceToken,
DateTimeOffset completedAt,
int workOrders,
int comments,
CancellationToken cancellationToken) =>
FinishAsync(runId, fenceToken, completedAt, "Succeeded", workOrders, comments, null, cancellationToken);
public Task FailAsync(
Guid runId,
Guid fenceToken,
DateTimeOffset completedAt,
string errorCode,
CancellationToken cancellationToken) =>
FinishAsync(runId, fenceToken, completedAt, "Failed", 0, 0, errorCode, cancellationToken);
private async Task FinishAsync(
Guid runId,
Guid fenceToken,
DateTimeOffset completedAt,
string state,
int workOrders,
int comments,
string? errorCode,
CancellationToken cancellationToken)
{
var job = await _context.WorkOrderReconciliationJobs
.SingleOrDefaultAsync(
j => j.Id == SingletonId
&& j.RunId == runId
&& j.FenceToken == fenceToken
&& j.State == "Running",
cancellationToken);
if (job == null)
return;
job.State = state;
job.CompletedAt = completedAt;
job.LeaseExpiresAt = null;
job.WorkOrdersProcessed = workOrders;
job.CommentsProcessed = comments;
job.ErrorCode = errorCode;
await _context.SaveChangesAsync(cancellationToken);
}
private async Task<WorkOrderReconciliationJob> GetOrCreateAsync(
CancellationToken cancellationToken)
{
var job = await _context.WorkOrderReconciliationJobs
.SingleOrDefaultAsync(j => j.Id == SingletonId, cancellationToken);
if (job != null)
return job;
job = new WorkOrderReconciliationJob { Id = SingletonId, RunId = Guid.NewGuid() };
_context.WorkOrderReconciliationJobs.Add(job);
try
{
await _context.SaveChangesAsync(cancellationToken);
return job;
}
catch (DbUpdateException)
{
_context.ChangeTracker.Clear();
return await _context.WorkOrderReconciliationJobs
.SingleAsync(j => j.Id == SingletonId, cancellationToken);
}
}
private static ReconciliationJobSnapshot Snapshot(WorkOrderReconciliationJob job) =>
new(
job.RunId,
job.State,
job.Reason,
job.RequestedAt,
job.StartedAt,
job.CompletedAt,
job.WorkOrdersProcessed,
job.CommentsProcessed,
job.ErrorCode);
}
}

View file

@ -0,0 +1,312 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public sealed class WorkOrderWebhookDataService : IWorkOrderWebhookDataService
{
private static readonly SemaphoreSlim InMemoryNumberLock = new(1, 1);
private readonly ApplicationDbContext _context;
public WorkOrderWebhookDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation,
CancellationToken cancellationToken)
{
var prior = await _context.WorkOrderWebhookDeliveries
.AsNoTracking()
.SingleOrDefaultAsync(d => d.DeliveryId == mutation.DeliveryId, cancellationToken);
if (prior != null)
return ExistingDeliveryResult(prior, mutation.BodySha256);
var workOrder = await _context.workOrders
.SingleOrDefaultAsync(
w => w.ExternalWorkOrderId == mutation.ExternalWorkOrderId,
cancellationToken);
if (workOrder == null)
{
var internalNumber = await AllocateInternalNumberAsync(cancellationToken);
workOrder = new WorkOrder
{
ExternalWorkOrderId = mutation.ExternalWorkOrderId,
ExternalSource = mutation.Source,
WorkerOrderNumber = mutation.WorkerOrderNumber,
InternalWONumber = internalNumber.ToString("D11"),
WorkerOrderTitle = mutation.Title ?? mutation.Description
?? $"Imported work order {mutation.ExternalWorkOrderId}",
Description = mutation.Description,
Source = mutation.Source,
istemplate = false,
CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime
};
_context.workOrders.Add(workOrder);
}
var staleState = mutation.IsStateEvent
&& !IsNewer(
mutation.UpdatedAt,
mutation.VersionHash,
workOrder.ExternalUpdatedAt,
workOrder.ExternalVersionHash);
if (mutation.IsStateEvent && !staleState)
{
var locationKey = mutation.SiteCode ?? mutation.Building;
if (!string.IsNullOrWhiteSpace(locationKey))
{
var locationIsNewer = await UpsertReceiptAsync(
mutation.Source,
"location",
locationKey,
mutation.UpdatedAt,
mutation.VersionHash,
mutation.ProcessedAt,
cancellationToken);
var location = await _context.Locations
.FirstOrDefaultAsync(
l => l.ExternalSource == mutation.Source
&& l.ExternalLocationId == locationKey,
cancellationToken);
if (location == null)
{
location = new Locations
{
ExternalSource = mutation.Source,
ExternalLocationId = locationKey,
Name = locationKey,
Title = mutation.Building,
Address1 = mutation.Address,
Status = "Active"
};
_context.Locations.Add(location);
}
else if (locationIsNewer)
{
location.Name = locationKey;
location.Title = mutation.Building;
location.Address1 = mutation.Address;
location.Status = "Active";
}
workOrder.Locations = location;
}
workOrder.WorkerOrderNumber = mutation.WorkerOrderNumber;
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
workOrder.Description = mutation.Description;
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
if (mutation.LifecycleStatus.HasValue)
workOrder.LifecycleStatus = mutation.LifecycleStatus.Value;
workOrder.Severity = mutation.Severity;
workOrder.Priority = mutation.Priority;
workOrder.ExternalAssignedTo = mutation.AssignedTo;
workOrder.ExternalRecordType = mutation.RecordType;
workOrder.Customer = mutation.Customer;
workOrder.SiteCode = mutation.SiteCode;
workOrder.Building = mutation.Building;
workOrder.DueDate = mutation.DueDate;
workOrder.DateReported = mutation.DateReported;
workOrder.ScheduledStart = mutation.ScheduledStart;
workOrder.Source = mutation.Source;
workOrder.SourceEmailS3Key = mutation.SourceEmailS3Key;
workOrder.ExternalSource = mutation.Source;
workOrder.istemplate = false;
workOrder.ExternalLastOccurredAt = mutation.OccurredAt;
workOrder.ExternalUpdatedAt = mutation.UpdatedAt;
workOrder.ExternalVersionHash = mutation.VersionHash;
}
if (mutation.CommentId != null)
{
var receiptIsNewer = await UpsertReceiptAsync(
mutation.Source,
"comment",
mutation.CommentId,
mutation.UpdatedAt,
mutation.VersionHash,
mutation.ProcessedAt,
cancellationToken);
var comment = await _context.Comments
.SingleOrDefaultAsync(
c => c.ExternalSource == mutation.Source
&& c.ExternalCommentId == mutation.CommentId,
cancellationToken);
if (comment == null)
{
comment = new Comments
{
ExternalSource = mutation.Source,
ExternalCommentId = mutation.CommentId,
RecordType = "WorkOrder",
WorkOrder = workOrder,
CreatedDate = mutation.OccurredAt.UtcDateTime,
};
_context.Comments.Add(comment);
}
if (receiptIsNewer)
{
comment.Commenttext = mutation.CommentText;
comment.Commenter = mutation.Commenter;
comment.CommentType = mutation.CommentType;
comment.ExternalUpdatedAt = mutation.UpdatedAt;
comment.ExternalVersionHash = mutation.VersionHash;
comment.ExternalSourceEmailS3Key = mutation.SourceEmailS3Key;
}
}
_context.WorkOrderWebhookDeliveries.Add(new WorkOrderWebhookDelivery
{
DeliveryId = mutation.DeliveryId,
EventType = mutation.EventType,
OccurredAt = mutation.OccurredAt,
ProcessedAt = mutation.ProcessedAt,
BodySha256 = mutation.BodySha256
});
try
{
await _context.SaveChangesAsync(cancellationToken);
return new WorkOrderWebhookPersistenceResult(
WorkOrderWebhookPersistenceStatus.Applied,
staleState);
}
catch (DbUpdateException)
{
_context.ChangeTracker.Clear();
var concurrent = await _context.WorkOrderWebhookDeliveries
.AsNoTracking()
.SingleOrDefaultAsync(d => d.DeliveryId == mutation.DeliveryId, cancellationToken);
if (concurrent == null)
throw;
return ExistingDeliveryResult(concurrent, mutation.BodySha256);
}
}
private async Task<long> AllocateInternalNumberAsync(CancellationToken cancellationToken)
{
if (_context.Database.IsSqlServer())
{
return await AllocateSqlServerInternalNumberAsync(cancellationToken);
}
await InMemoryNumberLock.WaitAsync(cancellationToken);
try
{
var values = await _context.workOrders
.AsNoTracking()
.Where(w => w.InternalWONumber != null)
.Select(w => w.InternalWONumber!)
.ToListAsync(cancellationToken);
return values
.Select(v => long.TryParse(v, out var parsed) ? parsed : 0L)
.DefaultIfEmpty()
.Max() + 1;
}
finally
{
InMemoryNumberLock.Release();
}
}
private async Task<long> AllocateSqlServerInternalNumberAsync(CancellationToken cancellationToken)
{
var connection = _context.Database.GetDbConnection();
var openedHere = false;
if (connection.State != System.Data.ConnectionState.Open)
{
await _context.Database.OpenConnectionAsync(cancellationToken);
openedHere = true;
}
try
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT NEXT VALUE FOR dbo.WorkOrderInternalNumberSequence";
var currentTransaction = _context.Database.CurrentTransaction;
if (currentTransaction != null)
{
command.Transaction = currentTransaction.GetDbTransaction();
}
var result = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture);
}
finally
{
if (openedHere)
await _context.Database.CloseConnectionAsync();
}
}
private async Task<bool> UpsertReceiptAsync(
string source,
string kind,
string externalId,
DateTimeOffset updatedAt,
string versionHash,
DateTimeOffset processedAt,
CancellationToken cancellationToken)
{
var receipt = await _context.WorkOrderExternalReceipts.SingleOrDefaultAsync(
r => r.Source == source && r.Kind == kind && r.ExternalId == externalId,
cancellationToken);
if (receipt == null)
{
_context.WorkOrderExternalReceipts.Add(new WorkOrderExternalReceipt
{
Source = source,
Kind = kind,
ExternalId = externalId,
UpdatedAt = updatedAt,
VersionHash = versionHash,
ProcessedAt = processedAt
});
return true;
}
if (!IsNewer(updatedAt, versionHash, receipt.UpdatedAt, receipt.VersionHash))
return false;
receipt.UpdatedAt = updatedAt;
receipt.VersionHash = versionHash;
receipt.ProcessedAt = processedAt;
return true;
}
private static bool IsNewer(
DateTimeOffset candidateUpdatedAt,
string candidateHash,
DateTimeOffset? currentUpdatedAt,
string? currentHash)
{
if (!currentUpdatedAt.HasValue)
return true;
var timestampComparison = candidateUpdatedAt.CompareTo(currentUpdatedAt.Value);
return timestampComparison > 0
|| (timestampComparison == 0
&& string.CompareOrdinal(candidateHash, currentHash ?? string.Empty) > 0);
}
private static WorkOrderWebhookPersistenceResult ExistingDeliveryResult(
WorkOrderWebhookDelivery delivery,
string bodySha256)
{
return new WorkOrderWebhookPersistenceResult(
string.Equals(delivery.BodySha256, bodySha256, StringComparison.OrdinalIgnoreCase)
? WorkOrderWebhookPersistenceStatus.Duplicate
: WorkOrderWebhookPersistenceStatus.HashConflict);
}
}
}

View file

@ -0,0 +1,28 @@
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorCompanyRosterDataService
{
// Loads the company roster from either a vendor row id and/or a company id,
// returning the company plus all non-deleted technician rows.
Task<VendorCompanyRosterReadModel?> GetRosterAsync(
int? vendorId,
int? companyId,
CancellationToken cancellationToken);
// Reconciles the company roster (add/update/soft-delete/rename) and updates the
// company Notes in a single SaveChangesAsync. A technician omitted from the
// snapshot is soft-deleted only when it has no open linked work orders; otherwise
// the whole save fails (VendorRosterConflictException) and nothing is persisted.
Task<VendorCompanyRosterReadModel> SaveRosterAsync(
VendorCompanyRosterWriteModel roster,
CancellationToken cancellationToken);
// Creates a new company together with its initial technicians (which may be zero)
// in a single SaveChangesAsync.
Task<VendorCompanyRosterReadModel> CreateRosterAsync(
VendorCompanyRosterWriteModel roster,
CancellationToken cancellationToken);
}
}

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Models;
namespace SeaHaven.DataServices.Interfaces
{
@ -13,6 +14,13 @@ namespace SeaHaven.DataServices.Interfaces
Task<IEnumerable<Vendor>> GetAllAsync();
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
// companies; active includes companies with zero non-deleted technicians or at
// least one active non-deleted technician; inactive includes companies with at
// least one inactive non-deleted technician.
Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
@ -22,6 +30,20 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null);
// SH-198 vendor directory: per-technician rows UNION company-only rows
// (companies with zero non-deleted technicians). Company-only rows carry a
// null VendorId and a populated CompanyId. Notes come from VendorCompany.Notes.
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
CancellationToken cancellationToken = default);
Task<Vendor> AddAsync(Vendor vendor);
Task UpdateAsync(Vendor vendor);
Task DeleteAsync(int id);

View file

@ -6,6 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

View file

@ -0,0 +1,52 @@
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderReconciliationDataService
{
Task<ReconciliationJobSnapshot> EnqueueAsync(
string reason,
DateTimeOffset now,
CancellationToken cancellationToken);
Task<ReconciliationLease?> TryAcquirePendingAsync(
DateTimeOffset now,
TimeSpan leaseDuration,
CancellationToken cancellationToken);
Task<ReconciliationJobSnapshot> GetStatusAsync(CancellationToken cancellationToken);
Task<bool> RenewLeaseAsync(
Guid runId,
Guid fenceToken,
DateTimeOffset now,
TimeSpan leaseDuration,
CancellationToken cancellationToken);
Task CompleteAsync(
Guid runId,
Guid fenceToken,
DateTimeOffset completedAt,
int workOrders,
int comments,
CancellationToken cancellationToken);
Task FailAsync(
Guid runId,
Guid fenceToken,
DateTimeOffset completedAt,
string errorCode,
CancellationToken cancellationToken);
}
public sealed record ReconciliationLease(Guid RunId, Guid FenceToken);
public sealed record ReconciliationJobSnapshot(
Guid? RunId,
string State,
string? Reason,
DateTimeOffset? RequestedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
int WorkOrdersProcessed,
int CommentsProcessed,
string? ErrorCode);
}

View file

@ -0,0 +1,59 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderWebhookDataService
{
Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation,
CancellationToken cancellationToken);
}
public sealed record WorkOrderWebhookMutation
{
public required string DeliveryId { get; init; }
public required string EventType { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public required DateTimeOffset ProcessedAt { get; init; }
public required string BodySha256 { get; init; }
public required string ExternalWorkOrderId { get; init; }
public required string WorkerOrderNumber { get; init; }
public required string Source { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public required string VersionHash { get; init; }
public bool IsStateEvent { get; init; }
public bool IsCancelled { get; init; }
public string? Title { get; init; }
public string? Description { get; init; }
public string? Status { get; init; }
public LifecycleStatus? LifecycleStatus { get; init; }
public string? Severity { get; init; }
public string? Priority { get; init; }
public string? AssignedTo { get; init; }
public string? RecordType { get; init; }
public string? SourceEmailS3Key { get; init; }
public string? Customer { get; init; }
public string? SiteCode { get; init; }
public string? Building { get; init; }
public string? Address { get; init; }
public DateTime? DueDate { get; init; }
public DateTime? DateReported { get; init; }
public DateTime? ScheduledStart { get; init; }
public DateTime? CreatedAt { get; init; }
public string? CommentId { get; init; }
public string? CommentText { get; init; }
public string? Commenter { get; init; }
public string? CommentType { get; init; }
}
public enum WorkOrderWebhookPersistenceStatus
{
Applied,
Duplicate,
HashConflict
}
public sealed record WorkOrderWebhookPersistenceResult(
WorkOrderWebhookPersistenceStatus Status,
bool StateMutationSkipped = false);
}

View file

@ -0,0 +1,74 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Models
{
public sealed class VendorCompanyRosterReadModel
{
public int CompanyId { get; set; }
public string Name { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public byte[]? RowVersion { get; set; }
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
}
public sealed class VendorRosterTechnicianReadModel
{
public int Id { get; set; }
public string? ContactName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? PreferredContact { get; set; }
public string? TradeSpecialties { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
}
public sealed class VendorCompanyRosterWriteModel
{
public int CompanyId { get; set; }
public byte[]? RowVersion { get; set; }
public string Name { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public string? ActorUserId { get; set; }
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
}
public sealed class RosterTechnicianWriteModel
{
public int? Id { get; set; }
public string? ContactName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? PreferredContact { get; set; }
public string? TradeSpecialties { get; set; }
public bool IsActive { get; set; } = true;
}
// Raised by the roster data service when a technician removed from the snapshot
// still has open linked work orders, so the whole reconcile must fail. Carries the
// blocking work orders so the API can surface a stable 409 without leaking internals.
public sealed class VendorRosterConflictException : Exception
{
public IReadOnlyList<LinkedWorkOrderInfo> BlockedWorkOrders { get; }
public VendorRosterConflictException(string message, IReadOnlyList<LinkedWorkOrderInfo> blockedWorkOrders)
: base(message)
{
BlockedWorkOrders = blockedWorkOrders;
}
}
}

View file

@ -0,0 +1,23 @@
namespace SeaHaven.DataServices.Models
{
public sealed class VendorDirectoryRow
{
public int? VendorId { get; set; }
public int CompanyId { get; set; }
public string? CompanyName { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? PreferredContact { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
}
}

View file

@ -0,0 +1,46 @@
using SeaHaven.Services.Helpers;
using Xunit;
namespace SeaHaven.Services.Tests;
public sealed class WorkOrderExternalVersionTests
{
[Fact]
public void Work_order_inputs_from_webhook_and_reconciliation_have_the_same_canonical_hash()
{
var timestamp = DateTimeOffset.Parse("2026-07-24T12:00:00+02:00");
var webhook = new WorkOrderExternalVersion.WorkOrder(
"42", "new", "Leaking pipe", "Acme", "S1", "A", "1 Main St", "2", "High",
"Alex", timestamp, timestamp.AddHours(1), timestamp.AddDays(1), "update", timestamp,
"emails/42.eml");
var reconciliation = webhook with { DateReported = timestamp.ToUniversalTime() };
Assert.Equal(
WorkOrderExternalVersion.ComputeWorkOrder(webhook),
WorkOrderExternalVersion.ComputeWorkOrder(reconciliation));
}
[Fact]
public void Comment_inputs_from_webhook_and_reconciliation_have_the_same_20_slot_canonical_hash()
{
var createdAt = DateTimeOffset.Parse("2026-07-24T11:59:00+02:00");
var ingestedAt = DateTimeOffset.Parse("2026-07-24T12:01:00+02:00");
var webhook = new WorkOrderExternalVersion.Comment(
"42", "comment-7", "comment", "Alex", "A note", createdAt, ingestedAt, "emails/42.eml");
var reconciliation = webhook with
{
CreatedAt = createdAt.ToUniversalTime(),
IngestedAt = ingestedAt.ToUniversalTime()
};
Assert.Equal(
WorkOrderExternalVersion.ComputeComment(webhook),
WorkOrderExternalVersion.ComputeComment(reconciliation));
Assert.Equal(
WorkOrderExternalVersion.Compute(
"42", null, null, null, null, null, null, null, null, null,
null, null, null, "comment", "2026-07-24T09:59:00.0000000+00:00", "emails/42.eml", "comment-7", "Alex",
"A note", "2026-07-24T10:01:00.0000000+00:00"),
WorkOrderExternalVersion.ComputeComment(webhook));
}
}

View file

@ -0,0 +1,72 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DependencyInjection;
using Xunit;
namespace SeaHaven.Services.Tests;
public sealed class WorkOrderReconciliationOptionsTests
{
[Fact]
public void Enabled_reconciliation_rejects_a_lease_shorter_than_worst_case_request_budget()
{
var values = new Dictionary<string, string?>
{
["WorkOrderReconciliation:Enabled"] = "true",
["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30",
["WorkOrderReconciliation:MaxRetries"] = "3",
["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "100",
["WorkOrderReconciliation:LeaseSeconds"] = "120"
};
using var provider = BuildProvider(values);
Assert.Throws<OptionsValidationException>(() => provider
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value);
}
[Fact]
public void Disabled_reconciliation_does_not_require_a_request_budget_lease()
{
var values = new Dictionary<string, string?>
{
["WorkOrderReconciliation:Enabled"] = "false",
["WorkOrderReconciliation:LeaseSeconds"] = "1"
};
using var provider = BuildProvider(values);
Assert.False(provider
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value.Enabled);
}
[Fact]
public void Enabled_reconciliation_rejects_a_lease_equal_to_the_request_budget()
{
var values = new Dictionary<string, string?>
{
["WorkOrderReconciliation:Enabled"] = "true",
["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30",
["WorkOrderReconciliation:MaxRetries"] = "0",
["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "0",
["WorkOrderReconciliation:LeaseSeconds"] = "30"
};
using var provider = BuildProvider(values);
Assert.Throws<OptionsValidationException>(() => provider
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value);
}
private static ServiceProvider BuildProvider(IDictionary<string, string?> values)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
var services = new ServiceCollection();
services.AddBusinessServices(configuration);
return services.BuildServiceProvider();
}
}

View file

@ -0,0 +1,22 @@
namespace SeaHaven.Services.Configuration
{
public sealed class WorkOrderReconciliationOptions
{
public const string SectionName = "WorkOrderReconciliation";
public bool Enabled { get; set; }
public bool RunOnStartup { get; set; } = true;
public string BaseUrl { get; set; } = "https://procurement-api.seahaven.com";
public string Region { get; set; } = "us-east-1";
public int PageSize { get; set; } = 100;
public int MaxPages { get; set; } = 10_000;
public int MaxCursorLength { get; set; } = 4_096;
public int RequestTimeoutSeconds { get; set; } = 30;
public int MaxRetries { get; set; } = 3;
public int RetryBaseDelayMilliseconds { get; set; } = 100;
public int MaxResponseBytes { get; set; } = 4_194_304;
public int PollSeconds { get; set; } = 10;
public int ScheduleMinutes { get; set; } = 60;
public int LeaseSeconds { get; set; } = 180;
}
}

View file

@ -0,0 +1,14 @@
namespace SeaHaven.Services.Configuration
{
public sealed class WorkOrderWebhookOptions
{
public const string SectionName = "WorkOrderWebhook";
public bool Enabled { get; set; }
public int MaxBodyBytes { get; set; } = 1_048_576;
public int AllowedClockSkewSeconds { get; set; } = 300;
public int SecretCacheSeconds { get; set; } = 300;
public string? Region { get; set; }
public string? SecretId { get; set; }
}
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.Services.Constants
{
public static class WorkOrderSourceIdentity
{
public const string WireSource = "procurement-ingest/workorder-shoc-emitter";
public const string CanonicalSource = "procurement";
}
}

View file

@ -0,0 +1,70 @@
namespace SeaHaven.Services.DTOs
{
public class VendorRosterDTO
{
public int CompanyId { get; set; }
public string Name { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public string? RowVersion { get; set; }
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
}
public class VendorRosterTechnicianDTO
{
public int? Id { get; set; }
public string? ContactName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? PreferredContact { get; set; }
public string? TradeSpecialties { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
}
public class RosterTechnicianInputDTO
{
public int? Id { get; set; }
public string? ContactName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? PreferredContact { get; set; }
public string? TradeSpecialties { get; set; }
public bool? IsActive { get; set; }
}
public class CreateVendorRosterDTO
{
public required string Name { get; set; }
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
public class ReconcileVendorRosterDTO
{
public required string RowVersion { get; set; }
public required string Name { get; set; }
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
}

View file

@ -21,6 +21,29 @@ namespace SeaHaven.Services.DTOs
public bool IsActive { get; set; }
}
// SH-198 vendor directory row. Id is the nullable vendor id (null for company-only
// rows that have zero technicians); CompanyId is always populated.
public class VendorDirectoryItemDTO
{
public int? Id { get; set; }
public int? CompanyId { get; set; }
public string? CompanyName { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? PreferredContact { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zipcode { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int TotalJobs { get; set; }
public bool IsActive { get; set; }
}
public class CreateVendorDTO
{
public required string Name { get; set; }

View file

@ -97,6 +97,9 @@ namespace SeaHaven.Services.DTOs
public string? DispatchNumber { get; set; }
public string? PONumber { get; set; }
public string? Status { get; set; }
public string? InvoiceNumber { get; set; }
public string? InvoiceStatus { get; set; }
public string? PaymentStatus { get; set; }
public decimal? NTEAmount { get; set; }
public string? Description { get; set; }
public DateTime? ScheduledDate { get; set; }
@ -109,6 +112,24 @@ namespace SeaHaven.Services.DTOs
public IEnumerable<PortalSignoffDTO> Signoffs { get; set; } = Enumerable.Empty<PortalSignoffDTO>();
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
public IEnumerable<VendorPortalDocumentDTO> Documents { get; set; } = Enumerable.Empty<VendorPortalDocumentDTO>();
}
public class VendorPortalDocumentDTO
{
public int Id { get; set; }
public string? OriginalFileName { get; set; }
public string? ContentType { get; set; }
public long SizeBytes { get; set; }
public string? ScanStatus { get; set; }
public string? ReviewStatus { get; set; }
public string? RejectionReason { get; set; }
public int Version { get; set; }
public int? ReplacesDocumentId { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? ScannedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
public bool CanDownload { get; set; }
}
public class AcceptDispatchResultDTO

View file

@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
using System.Reflection;
namespace SeaHaven.Services.DependencyInjection
@ -18,6 +19,37 @@ namespace SeaHaven.Services.DependencyInjection
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
services.Configure<VendorDocumentsOptions>(configuration.GetSection(VendorDocumentsOptions.SectionName));
services.AddOptions<WorkOrderWebhookOptions>()
.Bind(configuration.GetSection(WorkOrderWebhookOptions.SectionName))
.Validate(
o => o.MaxBodyBytes is > 0 and <= 1_048_576
&& o.AllowedClockSkewSeconds is >= 0 and <= 3600
&& o.SecretCacheSeconds is > 0 and <= 300,
"WorkOrderWebhook size, clock-skew, and cache settings are out of range.")
.Validate(
o => !o.Enabled || !string.IsNullOrWhiteSpace(o.SecretId),
"WorkOrderWebhook requires a non-empty SecretId when enabled.")
.ValidateOnStart();
services.AddOptions<WorkOrderReconciliationOptions>()
.Bind(configuration.GetSection(WorkOrderReconciliationOptions.SectionName))
.Validate(
o => !o.Enabled
|| (o.BaseUrl == "https://procurement-api.seahaven.com"
&& o.Region == "us-east-1"
&& o.PageSize is >= 1 and <= 500
&& o.MaxPages is >= 1 and <= 100_000
&& o.MaxCursorLength is >= 1 and <= 16_384
&& o.RequestTimeoutSeconds is >= 1 and <= 120
&& o.MaxRetries is >= 0 and <= 8
&& o.RetryBaseDelayMilliseconds is >= 0 and <= 10_000
&& o.MaxResponseBytes is >= 1 and <= 16_777_216
&& o.PollSeconds is >= 1 and <= 300
&& o.ScheduleMinutes is >= 1 and <= 10_080
&& o.LeaseSeconds is >= 30 and <= 3_600
&& HasSufficientReconciliationLease(o)),
"WorkOrderReconciliation configuration is invalid.")
.ValidateOnStart();
var assembly = Assembly.GetExecutingAssembly();
var allClasses = assembly.GetTypes()
@ -40,7 +72,30 @@ namespace SeaHaven.Services.DependencyInjection
services.AddValidatorsFromAssembly(assembly);
services.AddScoped<IWorkOrderReconciliationRunner>(
sp => (IWorkOrderReconciliationRunner)sp.GetRequiredService<IWorkOrderReconciliationService>());
return services;
}
private static bool HasSufficientReconciliationLease(WorkOrderReconciliationOptions options)
{
try
{
var attempts = checked((long)options.MaxRetries + 1);
var requestBudgetMilliseconds = checked(
attempts * options.RequestTimeoutSeconds * 1_000L);
var retryDelayMultiplier = checked((1L << options.MaxRetries) - 1L);
var retryDelayMilliseconds = checked(
retryDelayMultiplier * options.RetryBaseDelayMilliseconds);
var worstCaseMilliseconds = checked(
requestBudgetMilliseconds + retryDelayMilliseconds);
return checked((long)options.LeaseSeconds * 1_000L) > worstCaseMilliseconds;
}
catch (OverflowException)
{
return false;
}
}
}
}

View file

@ -6,37 +6,38 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderDerivedFields
{
/// <summary>
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
/// Past due overlay from DueDate (deadline) — never a WorkOrderType.
/// Independent of ScheduledDate (planned work / Schedule On).
/// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
if (workOrder.ScheduledDate == null)
if (workOrder.DueDate == null)
return false;
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
return false;
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
return DateOnly.FromDateTime(workOrder.DueDate.Value.Date) < today;
}
/// <summary>
/// Board read-model past-due check. Phase 1 compares UTC calendar dates;
/// Board read-model past-due check against DueDate. Compares UTC calendar dates;
/// business/location timezone support is planned for a later phase.
/// </summary>
public static bool IsPastDue(
DateTime? scheduledDate,
DateTime? dueDate,
LifecycleStatus? lifecycleStatus,
DateTime? utcNow = null)
{
if (!scheduledDate.HasValue)
if (!dueDate.HasValue)
return false;
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
return false;
var today = (utcNow ?? DateTime.UtcNow).Date;
return scheduledDate.Value.Date < today;
return dueDate.Value.Date < today;
}
/// <summary>

View file

@ -0,0 +1,97 @@
using System.Security.Cryptography;
using System.Text;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderExternalVersion
{
public static string ComputeWorkOrder(WorkOrder value) => Compute(
value.WorkOrderId,
value.Status,
value.Description,
value.Customer,
value.SiteCode,
value.Building,
value.Address,
value.Severity,
value.Priority,
value.AssignedTo,
Format(value.DateReported),
Format(value.ScheduledStart),
Format(value.DueDate),
value.RecordType,
Format(value.CreatedAt),
value.SourceEmailS3Key,
null,
null,
null,
null);
public static string ComputeComment(Comment value) => Compute(
value.WorkOrderId,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
value.RecordType,
Format(value.CreatedAt),
value.SourceEmailS3Key,
value.CommentId,
value.Commenter,
value.Text,
Format(value.IngestedAt));
public static string Compute(params string?[] values)
{
var buffer = new StringBuilder();
foreach (var value in values)
{
var normalized = value ?? string.Empty;
buffer.Append(normalized.Length).Append(':').Append(normalized).Append('|');
}
return Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(buffer.ToString())))
.ToLowerInvariant();
}
private static string? Format(DateTimeOffset? value) =>
value?.ToUniversalTime().ToString("O");
public sealed record WorkOrder(
string WorkOrderId,
string? Status,
string? Description,
string? Customer,
string? SiteCode,
string? Building,
string? Address,
string? Severity,
string? Priority,
string? AssignedTo,
DateTimeOffset? DateReported,
DateTimeOffset? ScheduledStart,
DateTimeOffset? DueDate,
string? RecordType,
DateTimeOffset? CreatedAt,
string? SourceEmailS3Key);
public sealed record Comment(
string WorkOrderId,
string CommentId,
string? RecordType,
string? Commenter,
string? Text,
DateTimeOffset? CreatedAt,
DateTimeOffset? IngestedAt,
string? SourceEmailS3Key);
}
}

View file

@ -0,0 +1,288 @@
using FluentValidation;
using FluentValidation.Results;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHaven.Services.Implementation
{
public class VendorCompanyRosterService : IVendorCompanyRosterService
{
private readonly IVendorCompanyRosterDataService _rosterDataService;
public VendorCompanyRosterService(IVendorCompanyRosterDataService rosterDataService)
{
_rosterDataService = rosterDataService;
}
public async Task<VendorRosterDTO?> GetRosterAsync(
int? vendorId,
int? companyId,
string userId,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
if (!vendorId.HasValue && !companyId.HasValue)
throw new ValidationException("A vendor id or company id is required.");
var roster = await _rosterDataService.GetRosterAsync(vendorId, companyId, cancellationToken);
return roster == null ? null : MapToDTO(roster);
}
public async Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians);
if (dto.Technicians.Any(technician => technician.Id.HasValue))
throw new ValidationException(new[]
{
new ValidationFailure(
nameof(CreateVendorRosterDTO.Technicians),
"Technician ids are not allowed when creating a vendor company.")
});
var writeModel = new VendorCompanyRosterWriteModel
{
Name = dto.Name,
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
Email = dto.Email,
Address = dto.Address,
City = dto.City,
State = dto.State,
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
var saved = await _rosterDataService.CreateRosterAsync(writeModel, cancellationToken);
return MapToDTO(saved);
}
public async Task<VendorRosterDTO> ReconcileRosterAsync(
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians);
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
// Mismatched ids: every non-null technician id must belong to this company.
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
var writeModel = new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
RowVersion = rowVersion,
Name = dto.Name,
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
Email = dto.Email,
Address = dto.Address,
City = dto.City,
State = dto.State,
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
var saved = await _rosterDataService.SaveRosterAsync(writeModel, cancellationToken);
return MapToDTO(saved);
}
private static void EnsureAuthenticated(string userId)
{
if (string.IsNullOrWhiteSpace(userId))
throw new UnauthorizedAccessException("An authenticated user is required.");
}
private static void NormalizeAndValidateCompanyFields(
string name,
string? companyPhone,
string? email,
string? googleMapsUrl,
List<RosterTechnicianInputDTO> technicians)
{
var failures = new List<ValidationFailure>();
if (string.IsNullOrWhiteSpace(name))
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Name), "Company name is required."));
// Contact group: the company must expose at least one contact channel.
var normalizedCompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(companyPhone);
if (string.IsNullOrWhiteSpace(normalizedCompanyPhone) && string.IsNullOrWhiteSpace(email))
failures.Add(new ValidationFailure(
nameof(CreateVendorRosterDTO.CompanyPhone),
"At least one company phone or email is required."));
if (!string.IsNullOrWhiteSpace(normalizedCompanyPhone) &&
!VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedCompanyPhone))
failures.Add(new ValidationFailure(
nameof(CreateVendorRosterDTO.CompanyPhone),
"Company phone must be in North American format: (XXX) XXX-XXXX"));
if (!string.IsNullOrWhiteSpace(email) && !BeValidEmail(email))
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Email), "Invalid email address."));
if (!string.IsNullOrWhiteSpace(googleMapsUrl) && !BeValidAbsoluteHttpsUrl(googleMapsUrl))
failures.Add(new ValidationFailure(
nameof(CreateVendorRosterDTO.GoogleMapsUrl),
"Google Maps URL must be an absolute HTTPS URL."));
// Technician phones must be valid North American format when provided.
for (var i = 0; i < technicians.Count; i++)
{
var technician = technicians[i];
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
technician.Phone = normalized;
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
failures.Add(new ValidationFailure(
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
"Phone must be in North American format: (XXX) XXX-XXXX"));
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
failures.Add(new ValidationFailure(
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
"Invalid email address."));
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
failures.Add(new ValidationFailure(
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
"PreferredContact must be one of: Phone, Email, Text"));
}
// Duplicate technician ids are not allowed.
var duplicateIds = technicians
.Where(t => t.Id.HasValue)
.GroupBy(t => t.Id!.Value)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToList();
if (duplicateIds.Count > 0)
failures.Add(new ValidationFailure(
nameof(CreateVendorRosterDTO.Technicians),
"Duplicate technician ids are not allowed."));
if (failures.Count > 0)
throw new ValidationException(failures);
}
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
int companyId,
List<RosterTechnicianInputDTO> technicians,
CancellationToken cancellationToken)
{
var requestedIds = technicians
.Where(t => t.Id.HasValue)
.Select(t => t.Id!.Value)
.Distinct()
.ToList();
if (requestedIds.Count == 0)
return;
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken);
if (roster == null)
throw new ValidationException(new[]
{
new ValidationFailure(nameof(ReconcileVendorRosterDTO.Name), $"No vendor company exists with ID {companyId}.")
});
var validIds = roster.Technicians.Select(t => t.Id).ToHashSet();
var mismatched = requestedIds.Where(id => !validIds.Contains(id)).ToList();
if (mismatched.Count > 0)
throw new ValidationException(new[]
{
new ValidationFailure(
nameof(ReconcileVendorRosterDTO.Technicians),
$"Technician ids do not belong to company {companyId}: {string.Join(", ", mismatched)}.")
});
}
private static List<RosterTechnicianWriteModel> MapTechnicians(List<RosterTechnicianInputDTO> technicians)
{
return technicians.Select(t => new RosterTechnicianWriteModel
{
Id = t.Id,
ContactName = t.ContactName,
Phone = t.Phone,
Email = t.Email,
PreferredContact = t.PreferredContact,
TradeSpecialties = t.TradeSpecialties,
IsActive = t.IsActive ?? true
}).ToList();
}
private static byte[] ParseRequiredRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
throw new ValidationException(new[]
{
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Row version is required.")
});
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
throw new ValidationException(new[]
{
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Invalid row version format.")
});
}
}
private static bool BeValidEmail(string? value) =>
System.Net.Mail.MailAddress.TryCreate(value, out var address) && address.Address == value;
private static bool BeValidAbsoluteHttpsUrl(string? value) =>
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
private static VendorRosterDTO MapToDTO(VendorCompanyRosterReadModel roster) => new()
{
CompanyId = roster.CompanyId,
Name = roster.Name,
CompanyPhone = roster.CompanyPhone,
Email = roster.Email,
Address = roster.Address,
City = roster.City,
State = roster.State,
Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes,
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
{
Id = t.Id,
ContactName = t.ContactName,
Phone = t.Phone,
Email = t.Email,
PreferredContact = t.PreferredContact,
TradeSpecialties = t.TradeSpecialties,
IsActive = t.IsActive,
TotalJobs = t.TotalJobs
}).ToList()
};
}
}

View file

@ -90,6 +90,7 @@ namespace SeaHaven.Services.Implementation
var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken);
var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken);
var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken);
var rawDocuments = await _documentData.ListForVendorDispatchAsync(id, session.Id, cancellationToken);
var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO
{
@ -149,6 +150,9 @@ namespace SeaHaven.Services.Implementation
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,
Status = dispatch.Status,
InvoiceNumber = dispatch.InvoiceNumber,
InvoiceStatus = dispatch.InvoiceStatus,
PaymentStatus = dispatch.PaymentStatus,
NTEAmount = dispatch.NTEAmount,
Description = dispatch.Description,
ScheduledDate = dispatch.ScheduledDate,
@ -192,7 +196,25 @@ namespace SeaHaven.Services.Implementation
SignedAt = s.SignedAt
}),
Comments = comments,
UpliftRequests = upliftRequests
UpliftRequests = upliftRequests,
Documents = rawDocuments.Select(document => new VendorPortalDocumentDTO
{
Id = document.Id,
OriginalFileName = document.OriginalFileName,
ContentType = document.ContentType,
SizeBytes = document.SizeBytes,
ScanStatus = document.ScanStatus,
ReviewStatus = document.ReviewStatus,
RejectionReason = document.RejectionReason,
Version = document.Version,
ReplacesDocumentId = document.ReplacesDocumentId,
CreatedDate = document.CreatedDate,
ScannedAt = document.ScannedAt,
ReviewedAt = document.ReviewedAt,
CanDownload = string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)
&& dispatch.Status != "Verified"
&& dispatch.Status != "Cancelled"
})
};
}
@ -510,7 +532,11 @@ namespace SeaHaven.Services.Implementation
}
public async Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(
VendorPortalSession session, int dispatchId, IFormFile file, CancellationToken cancellationToken)
VendorPortalSession session,
int dispatchId,
IFormFile file,
int? replacesDocumentId,
CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
{
@ -544,6 +570,20 @@ namespace SeaHaven.Services.Implementation
}
var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken);
var replacedDocument = latest;
if (replacesDocumentId.HasValue)
{
replacedDocument = await _documentData.GetForVendorDispatchAsync(
replacesDocumentId.Value,
dispatchId,
session.Id,
cancellationToken);
if (replacedDocument == null)
{
throw new InvalidOperationException(
"The completion document could not be replaced.");
}
}
var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
@ -560,7 +600,7 @@ namespace SeaHaven.Services.Implementation
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = version,
ReplacesDocumentId = latest?.Id,
ReplacesDocumentId = replacedDocument?.Id,
CreatedDate = now
};
@ -570,7 +610,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document",
OldValue = latest == null ? null : $"v{latest.Version}",
OldValue = replacedDocument == null ? null : $"v{replacedDocument.Version}",
NewValue = $"v{version}",
Action = "vendor_completion_document_uploaded",
ActorType = "vendor",

View file

@ -4,6 +4,7 @@ using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
@ -89,6 +90,58 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
CancellationToken cancellationToken = default)
{
var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets,
cancellationToken);
return new PagedResult<VendorDirectoryItemDTO>
{
Items = items.Select(MapToDirectoryItem).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new()
{
Id = row.VendorId,
CompanyId = row.CompanyId == 0 ? null : row.CompanyId,
CompanyName = row.CompanyName,
ContactName = row.ContactName,
Email = row.Email,
Phone = row.Phone,
CompanyPhone = row.CompanyPhone,
PreferredContact = row.PreferredContact,
Address = row.Address,
City = row.City,
State = row.State,
Zipcode = row.Zip,
TradeSpecialties = row.TradeSpecialties,
GoogleMapsUrl = row.GoogleMapsUrl,
Notes = row.Notes,
IsActive = row.IsActive,
TotalJobs = row.TotalJobs
};
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
{
NormalizePhoneFields(dto);
@ -569,25 +622,29 @@ namespace SeaHaven.Services.Implementation
public async Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken)
{
// Companies and Locations are sourced from company-owned VendorCompany fields
// (status semantics aligned to the directory union); Trades remain derived from
// non-deleted technician Vendors.
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
var companies = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
var companyFacets = companies
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(v => v.CompanyName)
.Select(v => new VendorFacetCompanyDTO
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(c => new VendorFacetCompanyDTO
{
Name = v.CompanyName,
CompanyId = v.CompanyId,
CompanyPhone = v.CompanyPhone,
Email = v.Email,
PreferredContact = v.PreferredContact,
Address = v.Address,
City = v.City,
State = v.State,
Zip = v.Zip,
GoogleMapsUrl = v.GoogleMapsUrl
Name = c.Name,
CompanyId = c.Id,
CompanyPhone = c.CompanyPhone,
Email = c.Email,
PreferredContact = null,
Address = c.Address,
City = c.City,
State = c.State,
Zip = c.Zip,
GoogleMapsUrl = c.GoogleMapsUrl
});
var trades = vendors
@ -596,20 +653,20 @@ namespace SeaHaven.Services.Implementation
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value);
var locations = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
.Select(v => new VendorFacetLocationDTO
var locations = companies
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))
.Select(c => new VendorFacetLocationDTO
{
City = v.City,
State = v.State,
Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
City = c.City,
State = c.State,
Label = string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
})
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
return new VendorFacetsDTO
{
Companies = companies,
Companies = companyFacets,
Trades = trades,
Locations = locations,
JobBuckets = new[]

View file

@ -85,7 +85,7 @@ namespace SeaHaven.Services.Implementation
RescheduleCount = row.RescheduleCount,
CarriedOver = row.CarriedOver,
WorkOrderType = row.WorkOrderType,
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.DueDate, row.LifecycleStatus, utcNow),
SiteCode = row.SiteCode,
LocationName = row.LocationName,
PocName = row.PocName,

View file

@ -61,9 +61,9 @@ namespace SeaHaven.Services.Implementation
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.DueDate, workOrder.LifecycleStatus);
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first.");
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update due date first.");
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
@ -231,7 +231,7 @@ namespace SeaHaven.Services.Implementation
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Overdue is not a work order type; it is derived from scheduledDate (isPastDue).");
"Overdue is not a work order type; it is derived from dueDate (isPastDue).");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");

View file

@ -0,0 +1,342 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService, IWorkOrderReconciliationRunner
{
private readonly IProcurementWorkOrderClient _client;
private readonly IWorkOrderWebhookDataService _workOrders;
private readonly IWorkOrderReconciliationDataService _jobs;
private readonly IOptionsMonitor<WorkOrderReconciliationOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<WorkOrderReconciliationService> _logger;
public WorkOrderReconciliationService(
IProcurementWorkOrderClient client,
IWorkOrderWebhookDataService workOrders,
IWorkOrderReconciliationDataService jobs,
IOptionsMonitor<WorkOrderReconciliationOptions> options,
TimeProvider timeProvider,
ILogger<WorkOrderReconciliationService> logger)
{
_client = client;
_workOrders = workOrders;
_jobs = jobs;
_options = options;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
ClaimsPrincipal user,
string reason,
CancellationToken cancellationToken)
{
RequireAdmin(user);
return await TriggerAsync(reason, cancellationToken);
}
public async Task<WorkOrderReconciliationStatus> GetStatusAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
RequireAdmin(user);
var status = await _jobs.GetStatusAsync(cancellationToken);
return new WorkOrderReconciliationStatus(
status.RunId,
status.State,
status.Reason,
status.RequestedAt,
status.StartedAt,
status.CompletedAt,
status.WorkOrdersProcessed,
status.CommentsProcessed,
status.ErrorCode);
}
public async Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
string reason,
CancellationToken cancellationToken)
{
if (!_options.CurrentValue.Enabled)
return new WorkOrderReconciliationTriggerResult(false, Guid.Empty);
var before = await _jobs.GetStatusAsync(cancellationToken);
var queued = await _jobs.EnqueueAsync(reason, _timeProvider.GetUtcNow(), cancellationToken);
return new WorkOrderReconciliationTriggerResult(
before.State is not ("Pending" or "Running"),
queued.RunId ?? Guid.Empty);
}
public async Task<bool> RunPendingAsync(CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (!options.Enabled)
return false;
var leaseDuration = TimeSpan.FromSeconds(options.LeaseSeconds);
var lease = await _jobs.TryAcquirePendingAsync(
_timeProvider.GetUtcNow(),
leaseDuration,
cancellationToken);
if (lease == null)
return false;
try
{
var (workOrders, comments) = await ReconcileAsync(
lease,
options,
leaseDuration,
cancellationToken);
await _jobs.CompleteAsync(
lease.RunId,
lease.FenceToken,
_timeProvider.GetUtcNow(),
workOrders,
comments,
cancellationToken);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Procurement work-order reconciliation run {RunId} failed.",
lease.RunId);
await _jobs.FailAsync(
lease.RunId,
lease.FenceToken,
_timeProvider.GetUtcNow(),
"reconciliation_failed",
cancellationToken);
return false;
}
}
private static void RequireAdmin(ClaimsPrincipal user)
{
if (user is null
|| !(user.Identity?.IsAuthenticated ?? false)
|| !user.IsInRole("Admin"))
{
throw new UnauthorizedAccessException();
}
}
private async Task<(int WorkOrders, int Comments)> ReconcileAsync(
ReconciliationLease lease,
WorkOrderReconciliationOptions options,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var cursor = (string?)null;
var seenCursors = new HashSet<string>(StringComparer.Ordinal);
var workOrderCount = 0;
var commentCount = 0;
for (var pageNumber = 0; pageNumber < options.MaxPages; pageNumber++)
{
var page = await _client.GetWorkOrdersAsync(cursor, options.PageSize, cancellationToken);
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
foreach (var item in page.Items)
{
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
await _workOrders.ApplyAsync(Map(item), cancellationToken);
workOrderCount++;
commentCount += await ReconcileCommentsAsync(
item.WorkOrderId,
lease,
options,
leaseDuration,
cancellationToken);
}
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
cursor = ValidateNextCursor(page.NextCursor, options.MaxCursorLength, seenCursors);
if (cursor == null)
return (workOrderCount, commentCount);
}
throw new InvalidOperationException("Procurement work-order page limit exceeded.");
}
private async Task<int> ReconcileCommentsAsync(
string workOrderId,
ReconciliationLease lease,
WorkOrderReconciliationOptions options,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
string? cursor = null;
var seenCursors = new HashSet<string>(StringComparer.Ordinal);
var count = 0;
for (var pageNumber = 0; pageNumber < options.MaxPages; pageNumber++)
{
var page = await _client.GetCommentsAsync(
workOrderId,
cursor,
options.PageSize,
cancellationToken);
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
foreach (var comment in page.Items)
{
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
await _workOrders.ApplyAsync(Map(comment), cancellationToken);
count++;
}
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
cursor = ValidateNextCursor(page.NextCursor, options.MaxCursorLength, seenCursors);
if (cursor == null)
return count;
}
throw new InvalidOperationException("Procurement comment page limit exceeded.");
}
private async Task EnsureLeaseAsync(
ReconciliationLease lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
if (!await _jobs.RenewLeaseAsync(
lease.RunId,
lease.FenceToken,
_timeProvider.GetUtcNow(),
leaseDuration,
cancellationToken))
{
throw new InvalidOperationException("Reconciliation lease was lost.");
}
}
private WorkOrderWebhookMutation Map(ProcurementWorkOrder item)
{
var updatedAt = item.UpdatedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
var hash = WorkOrderExternalVersion.ComputeWorkOrder(new WorkOrderExternalVersion.WorkOrder(
item.WorkOrderId,
item.WoStatus,
item.Description,
item.Customer,
item.SiteCode,
item.Building,
item.Address,
item.Severity,
item.Priority,
item.AssignedTo,
item.DateReported,
item.ScheduledStart,
item.DueDate,
item.RecordType,
item.CreatedAt,
item.SourceEmailS3Key));
var status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
var lifecycleStatus = item.WoStatus == "cancelled" || item.RecordType == "cancellation"
? LifecycleStatus.Canceled
: LifecycleStatusMapper.FromLegacyStatus(status);
return new WorkOrderWebhookMutation
{
DeliveryId = ReceiptId("work-order", item.WorkOrderId, updatedAt, hash),
EventType = "reconciliation.work_order",
OccurredAt = item.UpdatedAt ?? item.CreatedAt ?? updatedAt,
UpdatedAt = updatedAt,
ProcessedAt = _timeProvider.GetUtcNow(),
BodySha256 = hash,
VersionHash = hash,
ExternalWorkOrderId = item.WorkOrderId,
WorkerOrderNumber = item.WorkOrderId,
Source = WorkOrderSourceIdentity.CanonicalSource,
IsStateEvent = true,
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
Description = item.Description,
Status = status,
LifecycleStatus = lifecycleStatus,
Severity = item.Severity,
Priority = item.Priority ?? WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
AssignedTo = item.AssignedTo,
RecordType = item.RecordType,
SourceEmailS3Key = item.SourceEmailS3Key,
Customer = item.Customer,
SiteCode = item.SiteCode,
Building = item.Building,
Address = item.Address,
DueDate = item.DueDate?.UtcDateTime,
DateReported = item.DateReported?.UtcDateTime,
ScheduledStart = item.ScheduledStart?.UtcDateTime,
CreatedAt = item.CreatedAt?.UtcDateTime
};
}
private WorkOrderWebhookMutation Map(ProcurementWorkOrderComment item)
{
var updatedAt = item.IngestedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
var hash = WorkOrderExternalVersion.ComputeComment(new WorkOrderExternalVersion.Comment(
item.WorkOrderId,
item.CommentId,
item.RecordType,
item.Commenter,
item.Text,
item.CreatedAt,
item.IngestedAt,
item.SourceEmailS3Key));
return new WorkOrderWebhookMutation
{
DeliveryId = ReceiptId("comment", item.CommentId, updatedAt, hash),
EventType = "reconciliation.comment",
OccurredAt = item.CreatedAt ?? updatedAt,
UpdatedAt = updatedAt,
ProcessedAt = _timeProvider.GetUtcNow(),
BodySha256 = hash,
VersionHash = hash,
ExternalWorkOrderId = item.WorkOrderId,
WorkerOrderNumber = item.WorkOrderId,
Source = WorkOrderSourceIdentity.CanonicalSource,
IsStateEvent = false,
CommentId = item.CommentId,
CommentText = item.Text,
Commenter = item.Commenter,
CommentType = item.RecordType,
SourceEmailS3Key = item.SourceEmailS3Key
};
}
private static string? ValidateNextCursor(
string? cursor,
int maxLength,
HashSet<string> seen)
{
if (cursor == null)
return null;
if (cursor.Length == 0 || cursor.Length > maxLength || !seen.Add(cursor))
throw new InvalidOperationException("Procurement API returned an invalid cursor.");
return cursor;
}
private static string ReceiptId(
string kind,
string externalId,
DateTimeOffset updatedAt,
string hash)
{
var raw = $"{kind}:{externalId}:{updatedAt.ToUniversalTime():O}:{hash}";
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
.ToLowerInvariant();
return $"reconcile:{digest}";
}
}
}

View file

@ -0,0 +1,431 @@
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public sealed class WorkOrderWebhookService : IWorkOrderWebhookService
{
private static readonly Meter Meter = new("SeaHaven.WorkOrderWebhook", "1.0");
private static readonly Counter<long> Accepted = Meter.CreateCounter<long>("work_order_webhook.accepted");
private static readonly Counter<long> Duplicates = Meter.CreateCounter<long>("work_order_webhook.duplicate");
private static readonly Counter<long> Rejected = Meter.CreateCounter<long>("work_order_webhook.rejected");
private static readonly Counter<long> Invalid = Meter.CreateCounter<long>("work_order_webhook.invalid");
private static readonly Counter<long> Failed = Meter.CreateCounter<long>("work_order_webhook.failed");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
private readonly IWorkOrderWebhookSecretProvider _secretProvider;
private readonly IWorkOrderWebhookDataService _dataService;
private readonly IOptionsMonitor<WorkOrderWebhookOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<WorkOrderWebhookService> _logger;
public WorkOrderWebhookService(
IWorkOrderWebhookSecretProvider secretProvider,
IWorkOrderWebhookDataService dataService,
IOptionsMonitor<WorkOrderWebhookOptions> options,
TimeProvider timeProvider,
ILogger<WorkOrderWebhookService> logger)
{
_secretProvider = secretProvider;
_dataService = dataService;
_options = options;
_timeProvider = timeProvider;
_logger = logger;
}
public int MaximumBodyBytes =>
Math.Clamp(_options.CurrentValue.MaxBodyBytes, 1, 1_048_576);
public async Task<WorkOrderWebhookResult> ProcessAsync(
WorkOrderWebhookRequest request,
CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (!options.Enabled)
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Disabled);
if (!TryReadTimestamp(request.Timestamp, options.AllowedClockSkewSeconds, out var timestamp)
|| string.IsNullOrWhiteSpace(request.KeyId)
|| request.KeyId.Length > 128
|| string.IsNullOrWhiteSpace(request.Signature))
{
Rejected.Add(1);
return Unauthorized();
}
WorkOrderWebhookSecretResult secretResult;
try
{
secretResult = await _secretProvider.GetSecretAsync(request.KeyId, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
Failed.Add(1);
_logger.LogError("Work-order webhook signing secret could not be loaded.");
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable);
}
if (secretResult.Status == WorkOrderWebhookSecretStatus.Unavailable)
{
Failed.Add(1);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable);
}
if (secretResult.Status != WorkOrderWebhookSecretStatus.Found
|| secretResult.Secret is not { Length: > 0 })
{
Rejected.Add(1);
return Unauthorized();
}
var signatureValid = false;
try
{
signatureValid = VerifySignature(
request.Timestamp!,
request.Body,
request.Signature!,
secretResult.Secret);
}
finally
{
CryptographicOperations.ZeroMemory(secretResult.Secret);
}
if (!signatureValid)
{
Rejected.Add(1);
return Unauthorized();
}
WorkOrderWebhookEnvelope? envelope;
try
{
envelope = JsonSerializer.Deserialize<WorkOrderWebhookEnvelope>(request.Body, JsonOptions);
}
catch (JsonException)
{
Invalid.Add(1);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope);
}
if (!TryCreateMutation(envelope, request.Body, out var mutation))
{
Invalid.Add(1);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope);
}
try
{
var persisted = await _dataService.ApplyAsync(mutation!, cancellationToken);
switch (persisted.Status)
{
case WorkOrderWebhookPersistenceStatus.Applied:
Accepted.Add(1);
_logger.LogInformation(
"Work-order webhook delivery {DeliveryId} was accepted. State mutation skipped: {StateMutationSkipped}.",
mutation!.DeliveryId,
persisted.StateMutationSkipped);
return new WorkOrderWebhookResult(
WorkOrderWebhookStatus.Applied,
persisted.StateMutationSkipped);
case WorkOrderWebhookPersistenceStatus.Duplicate:
Duplicates.Add(1);
_logger.LogInformation(
"Work-order webhook delivery {DeliveryId} was already processed.",
mutation!.DeliveryId);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Duplicate);
case WorkOrderWebhookPersistenceStatus.HashConflict:
Rejected.Add(1);
_logger.LogWarning(
"Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.",
mutation!.DeliveryId);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict);
default:
throw new InvalidOperationException("Unknown persistence result.");
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Failed.Add(1);
_logger.LogError(
ex,
"Work-order webhook delivery {DeliveryId} could not be persisted.",
mutation!.DeliveryId);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable);
}
}
private bool TryReadTimestamp(
string? value,
int allowedClockSkewSeconds,
out DateTimeOffset timestamp)
{
timestamp = default;
if (string.IsNullOrEmpty(value)
|| value.Any(c => c is < '0' or > '9')
|| !long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds))
return false;
try
{
timestamp = DateTimeOffset.FromUnixTimeSeconds(seconds);
}
catch (ArgumentOutOfRangeException)
{
return false;
}
var skew = (_timeProvider.GetUtcNow() - timestamp).Duration();
return skew <= TimeSpan.FromSeconds(Math.Clamp(allowedClockSkewSeconds, 0, 3600));
}
private static bool VerifySignature(
string timestamp,
byte[] body,
string signature,
byte[] secret)
{
const string prefix = "v1=";
if (!signature.StartsWith(prefix, StringComparison.Ordinal)
|| signature.Length != prefix.Length + 64)
return false;
byte[] supplied;
try
{
supplied = Convert.FromHexString(signature[prefix.Length..]);
}
catch (FormatException)
{
return false;
}
var timestampBytes = Encoding.UTF8.GetBytes(timestamp);
var signed = new byte[timestampBytes.Length + 1 + body.Length];
timestampBytes.CopyTo(signed, 0);
signed[timestampBytes.Length] = (byte)'.';
body.CopyTo(signed, timestampBytes.Length + 1);
var expected = HMACSHA256.HashData(secret, signed);
return CryptographicOperations.FixedTimeEquals(expected, supplied);
}
private bool TryCreateMutation(
WorkOrderWebhookEnvelope? envelope,
byte[] body,
out WorkOrderWebhookMutation? mutation)
{
mutation = null;
if (envelope == null
|| envelope.SchemaVersion != 1
|| string.IsNullOrWhiteSpace(envelope.DeliveryId)
|| envelope.DeliveryId.Length > 128
|| string.IsNullOrWhiteSpace(envelope.EventType)
|| envelope.EventType.Length > 64
|| envelope.OccurredAt == null
|| !string.Equals(envelope.Source, WorkOrderSourceIdentity.WireSource, StringComparison.Ordinal)
|| envelope.Replay == null
|| envelope.Data == null
|| string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId)
|| envelope.Data.WorkOrderId.Length > 450
|| !KnownEvents.Contains(envelope.EventType))
return false;
var isComment = envelope.EventType == "work_order.comment_added";
if (isComment
&& (string.IsNullOrWhiteSpace(envelope.Data.CommentId)
|| envelope.Data.CommentId.Length > 450
|| string.IsNullOrWhiteSpace(envelope.Data.Text)))
return false;
var updatedAt = envelope.Data.UpdatedAt
?? envelope.Data.IngestedAt
?? envelope.OccurredAt;
if (updatedAt == null)
return false;
var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant();
var versionHash = isComment
? WorkOrderExternalVersion.ComputeComment(new WorkOrderExternalVersion.Comment(
envelope.Data.WorkOrderId,
envelope.Data.CommentId!,
envelope.Data.RecordType,
envelope.Data.Commenter,
envelope.Data.Text,
envelope.Data.CreatedAt,
envelope.Data.IngestedAt,
envelope.Data.SourceEmailS3Key))
: WorkOrderExternalVersion.ComputeWorkOrder(new WorkOrderExternalVersion.WorkOrder(
envelope.Data.WorkOrderId,
envelope.Data.WoStatus ?? envelope.Data.Status,
envelope.Data.Description,
envelope.Data.Customer,
envelope.Data.SiteCode,
envelope.Data.Building,
envelope.Data.Address,
envelope.Data.Severity,
envelope.Data.Priority,
envelope.Data.AssignedTo,
envelope.Data.DateReported,
envelope.Data.ScheduledStart,
envelope.Data.DueDate,
envelope.Data.RecordType,
envelope.Data.CreatedAt,
envelope.Data.SourceEmailS3Key));
var statusSource = envelope.Data.WoStatus ?? envelope.Data.Status;
var status = WorkOrderIngestFieldMapper.MapStatus(statusSource);
mutation = new WorkOrderWebhookMutation
{
DeliveryId = envelope.DeliveryId,
EventType = envelope.EventType,
OccurredAt = envelope.OccurredAt.Value,
ProcessedAt = _timeProvider.GetUtcNow(),
BodySha256 = bodyHash,
ExternalWorkOrderId = envelope.Data.WorkOrderId,
WorkerOrderNumber = envelope.Data.WorkOrderId,
Source = WorkOrderSourceIdentity.CanonicalSource,
UpdatedAt = updatedAt.Value,
VersionHash = versionHash,
IsStateEvent = !isComment,
IsCancelled = envelope.EventType == "work_order.cancelled",
Title = envelope.Data.Title,
Description = envelope.Data.Description,
Status = status,
LifecycleStatus = envelope.EventType == "work_order.cancelled"
? LifecycleStatus.Canceled
: LifecycleStatusMapper.FromLegacyStatus(status),
Severity = envelope.Data.Severity,
Priority = envelope.Data.Priority
?? WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity),
AssignedTo = envelope.Data.AssignedTo,
RecordType = envelope.Data.RecordType,
SourceEmailS3Key = envelope.Data.SourceEmailS3Key,
Customer = envelope.Data.Customer,
SiteCode = envelope.Data.SiteCode,
Building = envelope.Data.Building,
Address = envelope.Data.Address,
DueDate = envelope.Data.DueDate?.UtcDateTime,
DateReported = envelope.Data.DateReported?.UtcDateTime,
ScheduledStart = envelope.Data.ScheduledStart?.UtcDateTime,
CreatedAt = envelope.Data.CreatedAt?.UtcDateTime,
CommentId = isComment ? envelope.Data.CommentId : null,
CommentText = isComment ? envelope.Data.Text : null,
Commenter = isComment ? envelope.Data.Commenter : null,
CommentType = isComment ? envelope.Data.CommentType : null
};
return true;
}
private static WorkOrderWebhookResult Unauthorized() =>
new(WorkOrderWebhookStatus.Unauthorized);
private static readonly HashSet<string> KnownEvents = new(StringComparer.Ordinal)
{
"work_order.created",
"work_order.updated",
"work_order.cancelled",
"work_order.comment_added"
};
private sealed class WorkOrderWebhookEnvelope
{
[JsonPropertyName("schema_version")]
public int? SchemaVersion { get; set; }
[JsonPropertyName("delivery_id")]
public string? DeliveryId { get; set; }
[JsonPropertyName("event_type")]
public string? EventType { get; set; }
[JsonPropertyName("occurred_at")]
public DateTimeOffset? OccurredAt { get; set; }
public string? Source { get; set; }
public bool? Replay { get; set; }
public WorkOrderWebhookData? Data { get; set; }
}
private sealed class WorkOrderWebhookData
{
[JsonPropertyName("work_order_id")]
public string? WorkOrderId { get; set; }
public string? Description { get; set; }
public string? Title { get; set; }
[JsonPropertyName("wo_status")]
public string? WoStatus { get; set; }
public string? Status { get; set; }
public string? Severity { get; set; }
public string? Priority { get; set; }
public string? Customer { get; set; }
[JsonPropertyName("assigned_to")]
public string? AssignedTo { get; set; }
[JsonPropertyName("site_code")]
public string? SiteCode { get; set; }
public string? Building { get; set; }
public string? Address { get; set; }
[JsonPropertyName("due_date")]
public DateTimeOffset? DueDate { get; set; }
[JsonPropertyName("date_reported")]
public DateTimeOffset? DateReported { get; set; }
[JsonPropertyName("scheduled_start")]
public DateTimeOffset? ScheduledStart { get; set; }
[JsonPropertyName("created_at")]
public DateTimeOffset? CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTimeOffset? UpdatedAt { get; set; }
[JsonPropertyName("ingested_at")]
public DateTimeOffset? IngestedAt { get; set; }
[JsonPropertyName("record_type")]
public string? RecordType { get; set; }
[JsonPropertyName("source_email_s3_key")]
public string? SourceEmailS3Key { get; set; }
[JsonPropertyName("comment_id")]
public string? CommentId { get; set; }
public string? Text { get; set; }
public string? Commenter { get; set; }
[JsonPropertyName("comment_type")]
public string? CommentType { get; set; }
}
}
}

View file

@ -0,0 +1,53 @@
namespace SeaHaven.Services.Interfaces
{
public interface IProcurementWorkOrderClient
{
Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
string? cursor,
int limit,
CancellationToken cancellationToken);
Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
string workOrderId,
string? cursor,
int limit,
CancellationToken cancellationToken);
}
public sealed record ProcurementPage<T>(
IReadOnlyList<T> Items,
string? NextCursor);
public sealed record ProcurementWorkOrder
{
public required string WorkOrderId { get; init; }
public string? WoStatus { get; init; }
public string? Description { get; init; }
public string? Customer { get; init; }
public string? SiteCode { get; init; }
public string? Building { get; init; }
public string? Address { get; init; }
public string? Severity { get; init; }
public string? Priority { get; init; }
public string? AssignedTo { get; init; }
public DateTimeOffset? DateReported { get; init; }
public DateTimeOffset? ScheduledStart { get; init; }
public DateTimeOffset? DueDate { get; init; }
public string? RecordType { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
public string? SourceEmailS3Key { get; init; }
}
public sealed record ProcurementWorkOrderComment
{
public required string WorkOrderId { get; init; }
public required string CommentId { get; init; }
public string? RecordType { get; init; }
public string? Commenter { get; init; }
public string? Text { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public DateTimeOffset? IngestedAt { get; init; }
public string? SourceEmailS3Key { get; init; }
}
}

View file

@ -0,0 +1,24 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IVendorCompanyRosterService
{
Task<VendorRosterDTO?> GetRosterAsync(
int? vendorId,
int? companyId,
string userId,
CancellationToken cancellationToken);
Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken);
Task<VendorRosterDTO> ReconcileRosterAsync(
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken);
}
}

View file

@ -15,7 +15,7 @@ namespace SeaHaven.Services.Interfaces
Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken);
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken);
Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken);
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, CancellationToken cancellationToken);
Task<DownloadCompletionDocumentResultDTO> DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken);
}
}

View file

@ -16,6 +16,18 @@ namespace SeaHaven.Services.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null);
// SH-198 vendor directory: per-technician rows UNION company-only rows.
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
int page,
int pageSize,
string? search = null,
bool? isActive = true,
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
CancellationToken cancellationToken = default);
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
Task DeleteVendorAsync(int id, string userId);

View file

@ -0,0 +1,17 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Trusted internal surface for work-order reconciliation, used by hosted services and other
/// server-derived callers. Unlike <see cref="IWorkOrderReconciliationService"/> it does not
/// accept a <see cref="System.Security.Claims.ClaimsPrincipal"/> because callers are already
/// server-side and never expose an HTTP principal.
/// </summary>
public interface IWorkOrderReconciliationRunner
{
Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
string reason,
CancellationToken cancellationToken);
Task<bool> RunPendingAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,34 @@
using System.Security.Claims;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Controller-facing admin surface for work-order reconciliation. Every method enforces an
/// authenticated Admin principal at service entry before any data-service call, independent of
/// any HTTP-layer authorization filter.
/// </summary>
public interface IWorkOrderReconciliationService
{
Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
ClaimsPrincipal user,
string reason,
CancellationToken cancellationToken);
Task<WorkOrderReconciliationStatus> GetStatusAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken);
}
public sealed record WorkOrderReconciliationTriggerResult(bool Queued, Guid RunId);
public sealed record WorkOrderReconciliationStatus(
Guid? RunId,
string State,
string? Reason,
DateTimeOffset? RequestedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
int WorkOrdersProcessed,
int CommentsProcessed,
string? ErrorCode);
}

View file

@ -0,0 +1,20 @@
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderWebhookSecretProvider
{
Task<WorkOrderWebhookSecretResult> GetSecretAsync(
string keyId,
CancellationToken cancellationToken);
}
public enum WorkOrderWebhookSecretStatus
{
Found,
UnknownKey,
Unavailable
}
public sealed record WorkOrderWebhookSecretResult(
WorkOrderWebhookSecretStatus Status,
byte[]? Secret = null);
}

View file

@ -0,0 +1,32 @@
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderWebhookService
{
int MaximumBodyBytes { get; }
Task<WorkOrderWebhookResult> ProcessAsync(
WorkOrderWebhookRequest request,
CancellationToken cancellationToken);
}
public sealed record WorkOrderWebhookRequest(
string? Timestamp,
string? KeyId,
string? Signature,
byte[] Body);
public enum WorkOrderWebhookStatus
{
Applied,
Duplicate,
Unauthorized,
Disabled,
InvalidEnvelope,
HashConflict,
Unavailable
}
public sealed record WorkOrderWebhookResult(
WorkOrderWebhookStatus Status,
bool StateMutationSkipped = false);
}

View file

@ -20,9 +20,16 @@ namespace SeaHaven.Services.Validation
.EmailAddress().WithMessage("Invalid email address")
.When(x => !string.IsNullOrEmpty(x.Email));
// SH-199: technician (ContactName/Phone) is optional. The company must still
// expose at least one contact channel: a company phone or an email.
RuleFor(x => x)
.Must(dto => !string.IsNullOrWhiteSpace(dto.CompanyPhone) || !string.IsNullOrWhiteSpace(dto.Email))
.WithMessage("At least one company phone or email is required")
.OverridePropertyName(nameof(CreateVendorDTO.CompanyPhone));
RuleFor(x => x.ContactName)
.NotEmpty().WithMessage("Technician name is required")
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters");
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
.When(x => !string.IsNullOrEmpty(x.ContactName));
RuleFor(x => x.PreferredContact)
.Must(VendorValidationRules.BeValidPreferredContact)

View file

@ -0,0 +1,33 @@
{
"_readme": "Shared HMAC signing test vectors for the SHOC work-order webhook (docs/shoc-webhook-contract.md section 6). string_to_sign = \"{timestamp}.{raw_body}\" computed over the RAW UTF-8 body bytes; signature = lowercase hex of HMAC-SHA256(secret, string_to_sign), sent as header X-SH-Signature: \"v1=<hex>\" alongside X-SH-Timestamp: <unix seconds> and X-SH-Key-Id: <kid>. The HMAC key is the UTF-8 bytes of the 64-hex 'secret' string exactly as stored in the workorder-ingest/shoc-webhook-hmac secret (NO hex-decoding on either side). 'body' is the exact raw JSON string to sign, byte-for-byte: vector 2 contains non-ASCII UTF-8 (multi-byte characters must be signed as their UTF-8 bytes), vector 3 is an empty JSON object. Producer pins: lambdas/wo/shoc_emitter/delivery.py sign_body and scripts/replay_shoc_webhooks.py sign_body, both enforced by tests/test_shoc_emitter_delivery.py. The SHOC receiver should verify its implementation against every vector before activation.",
"vectors": [
{
"kid": "2026-07-20T00",
"secret_hex": "3afc6cf9cc5782b304fda7efa7f0a77c4b67ab7336536daa228960dae34aa2fe",
"timestamp": 1784642602,
"body": "{\"schema_version\": 1, \"delivery_id\": \"f2a9c1de-7b34-4d5c-9e01-8a6b5c4d3e2f\", \"event_type\": \"work_order.created\", \"occurred_at\": \"2026-07-16T14:03:22.114208+00:00\", \"source\": \"procurement-ingest/workorder-shoc-emitter\", \"replay\": false, \"data\": {\"work_order_id\": \"11144580730\", \"wo_status\": \"new\", \"description\": \"Dock door 14 won't close\", \"customer\": \"AMAZON\", \"site_code\": \"JFK8\", \"building\": \"JFK8\", \"address\": \"546 Gulf Ave, Staten Island, NY 10314\", \"severity\": \"3-Normal\", \"priority\": \"Medium\", \"assigned_to\": \"Sea Haven Industries\", \"date_reported\": \"2026-07-14T09:12:00\", \"scheduled_start\": null, \"due_date\": null, \"record_type\": \"new_work_order\", \"created_at\": \"2026-07-16T14:03:22.114208+00:00\", \"updated_at\": \"2026-07-16T14:03:22.114208+00:00\"}}",
"expected_signature": "4e6e171480e80eed333c966743c9da46595df86b4b65b76926e0781d2d3b0b46"
},
{
"kid": "2026-07-20T00",
"secret_hex": "3afc6cf9cc5782b304fda7efa7f0a77c4b67ab7336536daa228960dae34aa2fe",
"timestamp": 1784642700,
"body": "{\"schema_version\": 1, \"delivery_id\": \"0d1e2f3a-4b5c-6d7e-8f90-a1b2c3d4e5f6\", \"event_type\": \"work_order.comment_added\", \"occurred_at\": \"2026-07-16T14:05:00+00:00\", \"source\": \"procurement-ingest/workorder-shoc-emitter\", \"replay\": true, \"data\": {\"work_order_id\": \"11144580730\", \"comment_id\": \"11144580730#2026-04-27T23:51:48#a1b2c3d4e5f6\", \"record_type\": \"comment\", \"commenter\": \"APM Technician\", \"text\": \"Vendor dispatched — café access via süd door ✓\", \"created_at\": \"2026-04-27T23:51:48\", \"ingested_at\": \"2026-07-16T14:05:00+00:00\"}}",
"expected_signature": "6b61d5ac09bbf032b1a9c9b651bd7d5ea2ec925f94a857eeade52995801479e5"
},
{
"kid": "2026-06-20T00",
"secret_hex": "737719b2c437aa252a0db5f65411f828f244d403f7e625f7336da10ee985b2e4",
"timestamp": 1784000000,
"body": "{}",
"expected_signature": "ebe65980194b494c8de8d40cd6b8a42ff64c26749cf40fc6f36f59426dafa3c7"
},
{
"kid": "2026-06-20T00",
"secret_hex": "737719b2c437aa252a0db5f65411f828f244d403f7e625f7336da10ee985b2e4",
"timestamp": 1784650000,
"body": "{\"schema_version\": 1, \"event_type\": \"work_order.updated\", \"data\": {\"work_order_id\": \"999\"}}",
"expected_signature": "626c5d241887c6186fe021907b1a67a0f14ff2286d19df97785a33994c56ca54"
}
]
}

View file

@ -28,4 +28,10 @@
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\shoc-webhook-test-vectors.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,151 @@
using System.Text;
using System.Text.Json;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public sealed class ShocWebhookVectorTests
{
private const string FixturePath = "Fixtures/shoc-webhook-test-vectors.json";
[Fact]
public async Task All_four_shared_signing_vectors_pass_signature_verification()
{
var vectors = await LoadVectorsAsync();
Assert.True(vectors.Count >= 4, "fixture must ship at least the four shared vectors");
foreach (var vector in vectors)
{
var body = Encoding.UTF8.GetBytes(vector.Body);
var timestamp = vector.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture);
var signature = "v1=" + vector.ExpectedSignature;
var at = DateTimeOffset.FromUnixTimeSeconds(vector.Timestamp);
var data = new RecordingDataService();
var service = new WorkOrderWebhookService(
new VectorSecretProvider(vector.Kid, vector.SecretHex),
data,
new StaticOptionsMonitor<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
{
Enabled = true,
AllowedClockSkewSeconds = 300,
SecretId = "workorder-ingest/shoc-webhook-hmac"
}),
new FixedTimeProvider(at),
NullLogger<WorkOrderWebhookService>.Instance);
var result = await service.ProcessAsync(
new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body),
CancellationToken.None);
Assert.True(
result.Status != WorkOrderWebhookStatus.Unauthorized,
$"vector {vector.Kid}@{vector.Timestamp} failed signature verification");
}
}
[Fact]
public async Task Valid_envelope_vectors_are_applied_and_invalid_body_vectors_are_invalid_envelope()
{
var vectors = await LoadVectorsAsync();
var byIndex = vectors.Take(4).ToList();
var applied = await RunVectorAsync(byIndex[0]);
Assert.Equal(WorkOrderWebhookStatus.Applied, applied.Status);
var comment = await RunVectorAsync(byIndex[1]);
Assert.Equal(WorkOrderWebhookStatus.Applied, comment.Status);
var empty = await RunVectorAsync(byIndex[2]);
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, empty.Status);
var noSource = await RunVectorAsync(byIndex[3]);
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, noSource.Status);
}
private static async Task<WorkOrderWebhookResult> RunVectorAsync(Vector vector)
{
var body = Encoding.UTF8.GetBytes(vector.Body);
var timestamp = vector.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture);
var signature = "v1=" + vector.ExpectedSignature;
var at = DateTimeOffset.FromUnixTimeSeconds(vector.Timestamp);
var data = new RecordingDataService();
var service = new WorkOrderWebhookService(
new VectorSecretProvider(vector.Kid, vector.SecretHex),
data,
new StaticOptionsMonitor<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
{
Enabled = true,
AllowedClockSkewSeconds = 300,
SecretId = "workorder-ingest/shoc-webhook-hmac"
}),
new FixedTimeProvider(at),
NullLogger<WorkOrderWebhookService>.Instance);
return await service.ProcessAsync(
new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body),
CancellationToken.None);
}
private static async Task<List<Vector>> LoadVectorsAsync()
{
await using var stream = File.OpenRead(FixturePath);
var document = await JsonDocument.ParseAsync(stream);
var result = new List<Vector>();
foreach (var item in document.RootElement.GetProperty("vectors").EnumerateArray())
{
result.Add(new Vector(
item.GetProperty("kid").GetString()!,
item.GetProperty("secret_hex").GetString()!,
item.GetProperty("timestamp").GetInt64(),
item.GetProperty("body").GetString()!,
item.GetProperty("expected_signature").GetString()!));
}
return result;
}
private sealed record Vector(
string Kid,
string SecretHex,
long Timestamp,
string Body,
string ExpectedSignature);
private sealed class VectorSecretProvider : IWorkOrderWebhookSecretProvider
{
private readonly string _kid;
private readonly byte[] _secret;
public VectorSecretProvider(string kid, string secretHex)
{
_kid = kid;
_secret = Encoding.UTF8.GetBytes(secretHex);
}
public Task<WorkOrderWebhookSecretResult> GetSecretAsync(
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(string.Equals(keyId, _kid, StringComparison.Ordinal)
? new WorkOrderWebhookSecretResult(
WorkOrderWebhookSecretStatus.Found,
(byte[])_secret.Clone())
: new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey));
}
private sealed class RecordingDataService : IWorkOrderWebhookDataService
{
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation,
CancellationToken cancellationToken) =>
Task.FromResult(new WorkOrderWebhookPersistenceResult(
WorkOrderWebhookPersistenceStatus.Applied));
}
}

View file

@ -22,6 +22,8 @@ public class WorkOrderBoardMutationRulesTests
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
WorkOrderBoardFieldNames.DueDate, isPastDue: true));
}
[Fact]

View file

@ -342,7 +342,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
DueDate = today.AddDays(-3),
ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
@ -358,7 +359,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(1),
DueDate = today.AddDays(1),
ScheduledDate = today.AddDays(-5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
@ -392,7 +394,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
DueDate = today.AddDays(-3),
ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
@ -408,6 +411,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
DueDate = today.AddDays(1),
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
@ -444,7 +448,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 1,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(5),
DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(-5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
@ -452,7 +457,8 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 2,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
DueDate = today.AddDays(-3),
ScheduledDate = today.AddDays(5),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
@ -460,6 +466,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
DueDate = today.AddDays(-2),
ScheduledDate = today.AddDays(-2),
LifecycleStatus = LifecycleStatus.Completed,
InternalWONumber = "10000000003"
@ -468,6 +475,7 @@ public class WorkOrderAdvancedSearchServiceTests
{
Id = 4,
WorkOrderType = WorkOrderType.Reactive,
DueDate = today.AddDays(-1),
ScheduledDate = today.AddDays(-1),
LifecycleStatus = LifecycleStatus.Canceled,
InternalWONumber = "10000000004"

View file

@ -178,6 +178,7 @@ public class WorkOrderBoardServiceTests
{
Id = 1,
InternalWONumber = "10000000001",
DueDate = new DateTime(2026, 6, 20),
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
LifecycleStatus = LifecycleStatus.Scheduled
});

View file

@ -91,7 +91,8 @@ public class WorkOrderBoardUpdateServiceTests
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(5),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
@ -108,6 +109,145 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("PastDueStatusBlocked", ex.Code);
}
[Fact]
public async Task PatchField_RescheduleAlone_DoesNotClearPastDueBlock()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var futureSchedule = DateTime.UtcNow.Date.AddDays(7).ToString("yyyy-MM-dd");
var row = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = futureSchedule,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.True(row.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("PastDueStatusBlocked", ex.Code);
}
[Fact]
public async Task PatchField_FutureDueDate_ClearsPastDueAndAllowsStatusChange()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = DateTime.UtcNow.Date.AddDays(-3),
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var futureDue = DateTime.UtcNow.Date.AddDays(10).ToString("yyyy-MM-dd");
var afterDue = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DueDate,
Value = futureDue,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.False(afterDue.IsPastDue);
Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
Assert.False(afterStatus.IsPastDue);
}
[Fact]
public async Task PatchField_DueDate_DoesNotChangeScheduledDate()
{
var (context, service) = CreateSut();
var scheduled = new DateTime(2026, 6, 20);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = new DateTime(2026, 6, 18),
ScheduledDate = scheduled,
RescheduleCount = 2,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DueDate,
Value = "2026-07-01",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 7, 1), result.DueDate!.Value.Date);
Assert.Equal(scheduled, result.ScheduledDate);
Assert.Equal(2, result.RescheduleCount);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(scheduled, persisted.ScheduledDate);
Assert.Equal(2, persisted.RescheduleCount);
}
[Fact]
public async Task PatchField_ScheduledDate_DoesNotChangeDueDate()
{
var (context, service) = CreateSut();
var due = new DateTime(2026, 6, 18);
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DueDate = due,
ScheduledDate = new DateTime(2026, 6, 20),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = "2026-06-25",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate!.Value.Date);
Assert.Equal(due, result.DueDate);
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal(due, persisted.DueDate);
}
[Fact]
public async Task PatchField_RejectsOverdueAsWorkOrderType()
{
@ -132,7 +272,7 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("InvalidValue", ex.Code);
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]

View file

@ -6,28 +6,79 @@ namespace SeaHavenIndustries.Tests;
public class WorkOrderDerivedFieldsTests
{
private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow);
private static readonly DateTime Past = DateTime.UtcNow.Date.AddDays(-2);
private static readonly DateTime Future = DateTime.UtcNow.Date.AddDays(3);
private static readonly DateTime TodayDate = DateTime.UtcNow.Date;
[Fact]
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
public void IsPastDue_WhenDuePastAndScheduleFuture_ReturnsTrue()
{
var wo = new WorkOrder
{
ScheduledDate = DateTime.UtcNow.AddDays(-2),
DueDate = Past,
ScheduledDate = Future,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.True(WorkOrderDerivedFields.IsPastDue(wo.DueDate, wo.LifecycleStatus));
}
[Fact]
public void IsPastDue_WhenCompleted_ReturnsFalse()
public void IsPastDue_WhenDueFutureAndSchedulePast_ReturnsFalse()
{
var wo = new WorkOrder
{
ScheduledDate = DateTime.UtcNow.AddDays(-2),
LifecycleStatus = LifecycleStatus.Completed
DueDate = Future,
ScheduledDate = Past,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
}
[Fact]
public void IsPastDue_WhenDueDateNull_ReturnsFalse()
{
var wo = new WorkOrder
{
DueDate = null,
ScheduledDate = Past,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.False(WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Scheduled));
}
[Fact]
public void IsPastDue_WhenDueEqualsToday_ReturnsFalse()
{
var wo = new WorkOrder
{
DueDate = TodayDate,
ScheduledDate = Past,
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
}
[Theory]
[InlineData(LifecycleStatus.Completed)]
[InlineData(LifecycleStatus.Canceled)]
public void IsPastDue_WhenTerminal_ReturnsFalse(LifecycleStatus status)
{
var wo = new WorkOrder
{
DueDate = Past,
ScheduledDate = Past,
LifecycleStatus = status
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
Assert.False(WorkOrderDerivedFields.IsPastDue(Past, status));
}
[Fact]

View file

@ -0,0 +1,22 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace SeaHavenIndustries.Tests;
public class WorkOrderMigrationDiscoveryTests
{
[Fact]
public void Phase7_ExternalWorkOrderIdUnique_is_discoverable_by_ef_runtime()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite("DataSource=:memory:")
.Options;
using var context = new ApplicationDbContext(options);
var migrations = context.Database.GetMigrations().ToList();
Assert.Contains("20260713120000_Phase7_ExternalWorkOrderIdUnique", migrations);
}
}

View file

@ -107,7 +107,8 @@ public class WorkOrderDetailServiceTests
Id = 1,
InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(-2),
DueDate = DateTime.UtcNow.AddDays(-2),
ScheduledDate = DateTime.UtcNow.AddDays(3),
Trade = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
DocStatus = DocStatus.No,

View file

@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Public-interface authorization tests for <see cref="WorkOrderReconciliationService"/>. These
/// prove the controller-facing admin surface enforces an authenticated Admin principal at service
/// entry (defense in depth beyond the HTTP [Authorize] filter) and rejects non-Admin or
/// unauthenticated principals BEFORE any data-service call. The trusted runner surface is also
/// covered to confirm it is the path that performs the actual data work.
/// </summary>
public sealed class WorkOrderReconciliationAuthTests
{
private static ClaimsPrincipal Unauthenticated() => new(new ClaimsIdentity());
private static ClaimsPrincipal AuthenticatedNonAdmin() =>
new(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "vendor") },
"Test"));
private static ClaimsPrincipal AuthenticatedAdmin() =>
new(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.Name, "admin@seahavenind.com"),
new Claim(ClaimTypes.Role, "Admin")
},
"Test"));
private static WorkOrderReconciliationService CreateService(
SpyJobs jobs,
bool enabled) =>
new(
new NoopClient(),
new NoopWorkOrders(),
jobs,
new TestOptions(new WorkOrderReconciliationOptions
{
Enabled = enabled,
LeaseSeconds = 120
}),
TimeProvider.System,
NullLogger<WorkOrderReconciliationService>.Instance);
[Fact]
public async Task Unauthenticated_trigger_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.TriggerAsync(Unauthenticated(), "admin", CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Non_admin_trigger_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.TriggerAsync(AuthenticatedNonAdmin(), "admin", CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Unauthenticated_status_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.GetStatusAsync(Unauthenticated(), CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Non_admin_status_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.GetStatusAsync(AuthenticatedNonAdmin(), CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Admin_trigger_delegates_to_runner_with_admin_reason()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
var result = await service.TriggerAsync(AuthenticatedAdmin(), "admin", CancellationToken.None);
Assert.True(result.Queued);
Assert.NotEqual(Guid.Empty, result.RunId);
Assert.True(jobs.GetStatusCalls >= 1);
Assert.Equal(1, jobs.EnqueueCalls);
Assert.Equal("admin", jobs.LastEnqueuedReason);
}
[Fact]
public async Task Admin_status_returns_reconciliation_state_after_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
var status = await service.GetStatusAsync(AuthenticatedAdmin(), CancellationToken.None);
Assert.Equal(1, jobs.GetStatusCalls);
Assert.NotNull(status);
Assert.Equal("Idle", status.State);
}
[Fact]
public async Task Admin_trigger_when_disabled_returns_not_queued_without_enqueue()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: false);
var result = await service.TriggerAsync(AuthenticatedAdmin(), "admin", CancellationToken.None);
Assert.False(result.Queued);
Assert.Equal(Guid.Empty, result.RunId);
Assert.Equal(0, jobs.EnqueueCalls);
}
private static void AssertDataAccessBlocked(SpyJobs jobs)
{
Assert.Equal(0, jobs.GetStatusCalls);
Assert.Equal(0, jobs.EnqueueCalls);
}
private sealed class SpyJobs : IWorkOrderReconciliationDataService
{
public int GetStatusCalls { get; private set; }
public int EnqueueCalls { get; private set; }
public string? LastEnqueuedReason { get; private set; }
public Task<ReconciliationJobSnapshot> EnqueueAsync(
string reason, DateTimeOffset now, CancellationToken cancellationToken)
{
EnqueueCalls++;
LastEnqueuedReason = reason;
return Task.FromResult(new ReconciliationJobSnapshot(
Guid.NewGuid(), "Pending", reason, now, null, null, 0, 0, null));
}
public Task<ReconciliationLease?> TryAcquirePendingAsync(
DateTimeOffset now, TimeSpan leaseDuration, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task<ReconciliationJobSnapshot> GetStatusAsync(CancellationToken cancellationToken)
{
GetStatusCalls++;
return Task.FromResult(new ReconciliationJobSnapshot(
Guid.NewGuid(), "Idle", null, null, null, null, 0, 0, null));
}
public Task<bool> RenewLeaseAsync(
Guid runId, Guid fenceToken, DateTimeOffset now,
TimeSpan leaseDuration, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task CompleteAsync(
Guid runId, Guid fenceToken, DateTimeOffset completedAt,
int workOrders, int comments, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task FailAsync(
Guid runId, Guid fenceToken, DateTimeOffset completedAt,
string errorCode, CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class NoopClient : IProcurementWorkOrderClient
{
public Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
string? cursor, int limit, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
string workOrderId, string? cursor, int limit,
CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class NoopWorkOrders : IWorkOrderWebhookDataService
{
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation, CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class TestOptions : IOptionsMonitor<WorkOrderReconciliationOptions>
{
public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value;
public WorkOrderReconciliationOptions CurrentValue { get; }
public WorkOrderReconciliationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(
Action<WorkOrderReconciliationOptions, string?> listener) => null;
}
}

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