mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
2 commits
49406fa32d
...
b14aac2acf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b14aac2acf | ||
|
|
c594d9f689 |
99 changed files with 190 additions and 21675 deletions
|
|
@ -1,16 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# 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
13
.github/dependabot.yml
vendored
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
4
.github/workflows/architecture-quality.yml
vendored
4
.github/workflows/architecture-quality.yml
vendored
|
|
@ -11,12 +11,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
|
|
|
|||
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
|
|
@ -1,34 +1,11 @@
|
|||
name: Backend CI
|
||||
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
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
|
||||
ci:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
solution: "SeaHavenIndustries.sln"
|
||||
run-tests: true
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -4,5 +4,3 @@ 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
262
.github/workflows/deploy.yml
vendored
|
|
@ -1,262 +0,0 @@
|
|||
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
9
.gitignore
vendored
|
|
@ -365,12 +365,3 @@ 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/
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,495 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,383 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,38 +33,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ using Data.SeaHavenIndustries.Enums;
|
|||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
|
@ -322,176 +321,4 @@ 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,11 +35,6 @@ 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(
|
||||
|
|
@ -50,18 +45,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
|||
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
|
||||
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
|
||||
|
||||
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(
|
||||
var service = new VendorPortalService(
|
||||
tokenService,
|
||||
new DispatchDataService(context),
|
||||
upliftData.Object,
|
||||
commentData.Object,
|
||||
Mock.Of<IUpliftDataService>(),
|
||||
Mock.Of<ICommentDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<IEmailSender>(),
|
||||
documentData,
|
||||
|
|
@ -69,10 +57,7 @@ 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
|
||||
{
|
||||
|
|
@ -116,28 +101,6 @@ 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()
|
||||
{
|
||||
|
|
@ -168,7 +131,6 @@ 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);
|
||||
|
|
@ -192,106 +154,6 @@ 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()
|
||||
{
|
||||
|
|
@ -307,173 +169,6 @@ 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))
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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;
|
||||
|
|
@ -147,43 +146,6 @@ 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()
|
||||
{
|
||||
|
|
@ -247,44 +209,20 @@ public class VendorServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateVendor_AcceptsOptionalTechnicianButRequiresContactGroupAndHttpsUrl()
|
||||
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
|
||||
{
|
||||
var validator = new CreateVendorValidation();
|
||||
|
||||
// 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
|
||||
var result = 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"
|
||||
});
|
||||
|
||||
badUrl.IsValid.Should().BeFalse();
|
||||
badUrl.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
|
||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -345,8 +283,7 @@ public class VendorServiceTests
|
|||
{
|
||||
Name = "Acme",
|
||||
CompanyId = 77,
|
||||
ContactName = "Riley",
|
||||
Email = "acme@example.com"
|
||||
ContactName = "Riley"
|
||||
}, "42");
|
||||
|
||||
saved.Should().NotBeNull();
|
||||
|
|
@ -390,8 +327,7 @@ public class VendorServiceTests
|
|||
{
|
||||
Name = "Acme",
|
||||
ContactName = "Riley",
|
||||
Phone = "+1 312 555 0100",
|
||||
Email = "acme@example.com"
|
||||
Phone = "+1 312 555 0100"
|
||||
}, "42");
|
||||
|
||||
saved!.Phone.Should().Be("(312) 555-0100");
|
||||
|
|
@ -552,102 +488,4 @@ 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,10 +35,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[FromQuery] string[]? companies = null,
|
||||
[FromQuery] string[]? trades = null,
|
||||
[FromQuery] string[]? locations = null,
|
||||
[FromQuery] string[]? jobBuckets = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
[FromQuery] string[]? jobBuckets = null)
|
||||
{
|
||||
var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync(
|
||||
var pagedResult = await _vendorService.GetVendorsPagedAsync(
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
|
|
@ -46,13 +45,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets,
|
||||
cancellationToken);
|
||||
jobBuckets);
|
||||
|
||||
var data = pagedResult.Items.Select(v => new
|
||||
{
|
||||
v.Id,
|
||||
CompanyName = v.CompanyName,
|
||||
CompanyName = v.Name,
|
||||
v.CompanyId,
|
||||
v.ContactName,
|
||||
v.Email,
|
||||
|
|
|
|||
|
|
@ -228,25 +228,15 @@ 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,
|
||||
[FromForm] int? replacesDocumentId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
public async Task<IActionResult> UploadCompletionDocument(int id, [FromForm] IFormFile file, 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,
|
||||
replacesDocumentId,
|
||||
cancellationToken);
|
||||
var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
|
|
@ -260,7 +250,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,332 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,30 +67,9 @@ 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(
|
||||
|
|
|
|||
|
|
@ -39,30 +39,6 @@
|
|||
"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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ 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.
|
||||
|
|
@ -75,17 +73,6 @@ 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);
|
||||
|
|
@ -95,36 +82,6 @@ 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)
|
||||
|
|
@ -140,10 +97,6 @@ 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)
|
||||
|
|
@ -176,9 +129,6 @@ 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; }
|
||||
|
|
@ -240,18 +190,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
|
@ -7,8 +5,6 @@ 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
|
|
@ -1,67 +0,0 @@
|
|||
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
|
|
@ -1,221 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,67 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,8 +22,6 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.HasSequence("WorkOrderInternalNumberSequence");
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -109,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
b.ToTable("Accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
|
||||
|
|
@ -177,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("Addresses");
|
||||
b.ToTable("Addresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
|
|
@ -336,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
b.ToTable("Assets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
|
||||
|
|
@ -373,89 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
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");
|
||||
b.ToTable("Categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
|
||||
|
|
@ -507,9 +423,77 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceKey", "IsActive");
|
||||
b.HasIndex("ServiceKey", "IsActive")
|
||||
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
|
||||
|
||||
b.ToTable("CompletionDocTemplates");
|
||||
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);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
|
||||
|
|
@ -575,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("ContactDetails");
|
||||
b.ToTable("ContactDetails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
|
||||
|
|
@ -667,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Contacts");
|
||||
b.ToTable("Contacts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
|
||||
|
|
@ -685,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Departments");
|
||||
b.ToTable("Departments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
|
||||
|
|
@ -804,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("Dispatches");
|
||||
b.ToTable("Dispatches", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
|
||||
|
|
@ -863,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchChecklistItems");
|
||||
b.ToTable("DispatchChecklistItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
|
||||
|
|
@ -917,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.ToTable("DispatchSignoffs");
|
||||
b.ToTable("DispatchSignoffs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
|
||||
|
|
@ -987,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
|
||||
b.ToTable("DispatchUpliftRequests");
|
||||
b.ToTable("DispatchUpliftRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
|
|
@ -1010,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchWorkOrders");
|
||||
b.ToTable("DispatchWorkOrders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
|
||||
|
|
@ -1040,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DropdownOptions");
|
||||
b.ToTable("DropdownOptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
|
||||
|
|
@ -1145,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("RegionId");
|
||||
|
||||
b.ToTable("Employees");
|
||||
b.ToTable("Employees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
|
||||
|
|
@ -1185,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("EmployeeId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("EmployeeAddresses");
|
||||
b.ToTable("EmployeeAddresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
|
||||
|
|
@ -1213,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeeEmails");
|
||||
b.ToTable("EmployeeEmails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
|
||||
|
|
@ -1243,7 +1227,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeePhones");
|
||||
b.ToTable("EmployeePhones", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
|
|
@ -1391,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("FollowUps");
|
||||
b.ToTable("FollowUps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
|
||||
|
|
@ -1416,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ForgetPasswordCodes");
|
||||
b.ToTable("ForgetPasswordCodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
|
||||
|
|
@ -1434,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobTitles");
|
||||
b.ToTable("JobTitles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||
|
|
@ -1466,14 +1450,6 @@ 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");
|
||||
|
||||
|
|
@ -1512,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Locations");
|
||||
b.ToTable("Locations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
|
||||
|
|
@ -1570,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("PMSchedules");
|
||||
b.ToTable("PMSchedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
|
||||
|
|
@ -1644,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("Quotes");
|
||||
b.ToTable("Quotes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
|
||||
|
|
@ -1692,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("QuoteId");
|
||||
|
||||
b.ToTable("QuotesLines");
|
||||
b.ToTable("QuotesLines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
|
||||
|
|
@ -1710,7 +1686,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Regions");
|
||||
b.ToTable("Regions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
|
|
@ -1812,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaskListTemplates");
|
||||
b.ToTable("TaskListTemplates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
|
|
@ -1857,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("TaskListTemplateId");
|
||||
|
||||
b.ToTable("TaskListTemplateItems");
|
||||
b.ToTable("TaskListTemplateItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||
|
|
@ -1959,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Templates");
|
||||
b.ToTable("Templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
|
|
@ -2047,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("Vendors");
|
||||
b.ToTable("Vendors", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
|
||||
|
|
@ -2106,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAccessTokens");
|
||||
b.ToTable("VendorAccessTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
|
||||
|
|
@ -2194,14 +2170,6 @@ 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)");
|
||||
|
||||
|
|
@ -2371,28 +2339,6 @@ 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)");
|
||||
|
|
@ -2553,6 +2499,10 @@ 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] <> ''");
|
||||
|
|
@ -2560,6 +2510,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("InternalWONumber")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("PrimaryDispatchId");
|
||||
|
|
@ -2571,12 +2524,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.ToTable("workOrders");
|
||||
b.ToTable("workOrders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||
|
|
@ -2621,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderAttachments");
|
||||
b.ToTable("workOrderAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
|
||||
|
|
@ -2671,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("WorkOrderAuditLogs");
|
||||
b.ToTable("WorkOrderAuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
|
||||
|
|
@ -2715,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderCategories");
|
||||
b.ToTable("workOrderCategories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
|
||||
|
|
@ -2762,50 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
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");
|
||||
b.ToTable("WorkOrderContacts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
|
|
@ -2834,64 +2739,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "FieldName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderFieldLocks");
|
||||
b.ToTable("WorkOrderFieldLocks", (string)null);
|
||||
});
|
||||
|
||||
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 =>
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
|
|
@ -2899,33 +2750,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("BodySha256")
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DeliveryId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset>("ProcessedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeliveryId")
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWebhookDeliveries");
|
||||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
|
|
@ -3590,10 +3433,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();
|
||||
|
|
@ -3601,10 +3444,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();
|
||||
|
|
|
|||
|
|
@ -11,10 +11,6 @@ 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))]
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
|
|
@ -14,10 +13,6 @@ 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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; } = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -9,16 +9,6 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -1,320 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore;
|
|||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
|
|
@ -80,39 +79,6 @@ 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,
|
||||
|
|
@ -141,196 +107,6 @@ 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,
|
||||
|
|
@ -374,7 +150,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Distinct()
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
if (selectedTrades.Length > 0)
|
||||
query = query.Where(BuildTradePredicate<Vendor>(selectedTrades));
|
||||
query = query.Where(BuildTradePredicate(selectedTrades));
|
||||
|
||||
var selectedLocations = locations?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
|
|
@ -382,7 +158,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Distinct()
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
if (selectedLocations.Length > 0)
|
||||
query = query.Where(BuildLocationPredicate<Vendor>(selectedLocations));
|
||||
query = query.Where(BuildLocationPredicate(selectedLocations));
|
||||
|
||||
var result = ProjectWithTotalJobs(query);
|
||||
var selectedJobBuckets = jobBuckets?
|
||||
|
|
@ -440,10 +216,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<T, bool>> BuildTradePredicate<T>(
|
||||
private static Expression<Func<Vendor, bool>> BuildTradePredicate(
|
||||
IReadOnlyCollection<string> selectedTrades)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
||||
var specialties = Expression.Coalesce(
|
||||
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||
Expression.Constant(string.Empty));
|
||||
|
|
@ -471,13 +247,13 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
}
|
||||
|
||||
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
||||
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
|
||||
IReadOnlyCollection<string> selectedLocations)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
||||
var label = BuildLocationLabelBody(vendor);
|
||||
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
||||
|
||||
|
|
@ -485,7 +261,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
foreach (var location in selectedLocations)
|
||||
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
||||
|
||||
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
}
|
||||
|
||||
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
||||
|
|
|
|||
|
|
@ -30,16 +30,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
|
|
@ -14,13 +13,6 @@ 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,
|
||||
|
|
@ -30,20 +22,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
namespace SeaHaven.Services.Constants
|
||||
{
|
||||
public static class WorkOrderSourceIdentity
|
||||
{
|
||||
public const string WireSource = "procurement-ingest/workorder-shoc-emitter";
|
||||
|
||||
public const string CanonicalSource = "procurement";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -21,29 +21,6 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -97,9 +97,6 @@ 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; }
|
||||
|
|
@ -112,24 +109,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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
|
||||
|
|
@ -19,37 +18,6 @@ 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()
|
||||
|
|
@ -72,30 +40,7 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +90,6 @@ 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
|
||||
{
|
||||
|
|
@ -150,9 +149,6 @@ 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,
|
||||
|
|
@ -196,25 +192,7 @@ namespace SeaHaven.Services.Implementation
|
|||
SignedAt = s.SignedAt
|
||||
}),
|
||||
Comments = comments,
|
||||
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"
|
||||
})
|
||||
UpliftRequests = upliftRequests
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -532,11 +510,7 @@ namespace SeaHaven.Services.Implementation
|
|||
}
|
||||
|
||||
public async Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(
|
||||
VendorPortalSession session,
|
||||
int dispatchId,
|
||||
IFormFile file,
|
||||
int? replacesDocumentId,
|
||||
CancellationToken cancellationToken)
|
||||
VendorPortalSession session, int dispatchId, IFormFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
|
|
@ -570,20 +544,6 @@ 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)}";
|
||||
|
||||
|
|
@ -600,7 +560,7 @@ namespace SeaHaven.Services.Implementation
|
|||
ScanStatus = "Pending",
|
||||
ReviewStatus = "Processing",
|
||||
Version = version,
|
||||
ReplacesDocumentId = replacedDocument?.Id,
|
||||
ReplacesDocumentId = latest?.Id,
|
||||
CreatedDate = now
|
||||
};
|
||||
|
||||
|
|
@ -610,7 +570,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
DispatchId = dispatchId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document",
|
||||
OldValue = replacedDocument == null ? null : $"v{replacedDocument.Version}",
|
||||
OldValue = latest == null ? null : $"v{latest.Version}",
|
||||
NewValue = $"v{version}",
|
||||
Action = "vendor_completion_document_uploaded",
|
||||
ActorType = "vendor",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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;
|
||||
|
|
@ -90,58 +89,6 @@ 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);
|
||||
|
|
@ -622,29 +569,25 @@ 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 companyFacets = companies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
.GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
var companies = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
|
||||
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => new VendorFacetCompanyDTO
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new VendorFacetCompanyDTO
|
||||
{
|
||||
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
|
||||
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
|
||||
});
|
||||
|
||||
var trades = vendors
|
||||
|
|
@ -653,20 +596,20 @@ namespace SeaHaven.Services.Implementation
|
|||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value);
|
||||
|
||||
var locations = companies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))
|
||||
.Select(c => new VendorFacetLocationDTO
|
||||
var locations = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
|
||||
.Select(v => new VendorFacetLocationDTO
|
||||
{
|
||||
City = c.City,
|
||||
State = c.State,
|
||||
Label = string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||
City = v.City,
|
||||
State = v.State,
|
||||
Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||
})
|
||||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(location => location.Label);
|
||||
|
||||
return new VendorFacetsDTO
|
||||
{
|
||||
Companies = companyFacets,
|
||||
Companies = companies,
|
||||
Trades = trades,
|
||||
Locations = locations,
|
||||
JobBuckets = new[]
|
||||
|
|
|
|||
|
|
@ -1,342 +0,0 @@
|
|||
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,431 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, int? replacesDocumentId, CancellationToken cancellationToken);
|
||||
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken);
|
||||
Task<DownloadCompletionDocumentResultDTO> DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,18 +16,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -20,16 +20,9 @@ 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)
|
||||
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.ContactName));
|
||||
.NotEmpty().WithMessage("Technician name is required")
|
||||
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters");
|
||||
|
||||
RuleFor(x => x.PreferredContact)
|
||||
.Must(VendorValidationRules.BeValidPreferredContact)
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"_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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -28,10 +28,4 @@
|
|||
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\shoc-webhook-test-vectors.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class WorkOrderReconciliationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Full_scan_paginates_work_orders_and_each_comment_collection()
|
||||
{
|
||||
var client = new MockClient(
|
||||
new[]
|
||||
{
|
||||
new ProcurementPage<ProcurementWorkOrder>(
|
||||
new[] { WorkOrder("2", "new") },
|
||||
"next"),
|
||||
new ProcurementPage<ProcurementWorkOrder>(
|
||||
new[] { WorkOrder("1", "cancelled") },
|
||||
null)
|
||||
},
|
||||
new Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>>
|
||||
{
|
||||
["2"] = new Queue<ProcurementPage<ProcurementWorkOrderComment>>(new[]
|
||||
{
|
||||
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||
new[] { Comment("2", "c2") },
|
||||
"comments-next"),
|
||||
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||
Array.Empty<ProcurementWorkOrderComment>(),
|
||||
null)
|
||||
}),
|
||||
["1"] = new Queue<ProcurementPage<ProcurementWorkOrderComment>>(new[]
|
||||
{
|
||||
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||
new[] { Comment("1", "c1") },
|
||||
null)
|
||||
})
|
||||
});
|
||||
var mutations = new RecordingWorkOrders();
|
||||
var jobs = new RecordingJobs();
|
||||
var service = Create(client, mutations, jobs);
|
||||
|
||||
Assert.True(await service.RunPendingAsync(CancellationToken.None));
|
||||
|
||||
Assert.Equal(4, mutations.Items.Count);
|
||||
Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1" && m.IsCancelled);
|
||||
Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1"
|
||||
&& m.LifecycleStatus == LifecycleStatus.Canceled);
|
||||
Assert.Contains(mutations.Items, m => m.CommentId == "c2");
|
||||
Assert.Equal(2, jobs.CompletedWorkOrders);
|
||||
Assert.Equal(2, jobs.CompletedComments);
|
||||
Assert.True(jobs.RenewCount >= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_cursor_fails_bounded_run_instead_of_looping()
|
||||
{
|
||||
var page = new ProcurementPage<ProcurementWorkOrder>(
|
||||
Array.Empty<ProcurementWorkOrder>(),
|
||||
"same");
|
||||
var service = Create(
|
||||
new MockClient(new[] { page, page }, new()),
|
||||
new RecordingWorkOrders(),
|
||||
new RecordingJobs());
|
||||
|
||||
Assert.False(await service.RunPendingAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lost_lease_after_fetch_prevents_work_order_persistence()
|
||||
{
|
||||
var mutations = new RecordingWorkOrders();
|
||||
var jobs = new RecordingJobs { LoseLeaseOnRenewal = true };
|
||||
var service = Create(
|
||||
new MockClient(
|
||||
new[]
|
||||
{
|
||||
new ProcurementPage<ProcurementWorkOrder>(
|
||||
new[] { WorkOrder("1", "new") },
|
||||
null)
|
||||
},
|
||||
new()),
|
||||
mutations,
|
||||
jobs);
|
||||
|
||||
Assert.False(await service.RunPendingAsync(CancellationToken.None));
|
||||
Assert.Empty(mutations.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_records_without_timestamps_use_stable_epoch_freshness()
|
||||
{
|
||||
var client = new MockClient(
|
||||
new[]
|
||||
{
|
||||
new ProcurementPage<ProcurementWorkOrder>(
|
||||
new[]
|
||||
{
|
||||
new ProcurementWorkOrder
|
||||
{
|
||||
WorkOrderId = "42",
|
||||
Description = "Legacy mock"
|
||||
}
|
||||
},
|
||||
null)
|
||||
},
|
||||
new Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>>
|
||||
{
|
||||
["42"] = new Queue<ProcurementPage<ProcurementWorkOrderComment>>(new[]
|
||||
{
|
||||
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||
new[]
|
||||
{
|
||||
new ProcurementWorkOrderComment
|
||||
{
|
||||
WorkOrderId = "42",
|
||||
CommentId = "legacy-comment",
|
||||
Text = "Legacy comment"
|
||||
}
|
||||
},
|
||||
null)
|
||||
})
|
||||
});
|
||||
var mutations = new RecordingWorkOrders();
|
||||
var service = Create(client, mutations, new RecordingJobs());
|
||||
|
||||
Assert.True(await service.RunPendingAsync(CancellationToken.None));
|
||||
|
||||
Assert.Equal(2, mutations.Items.Count);
|
||||
Assert.All(mutations.Items, mutation =>
|
||||
Assert.Equal(DateTimeOffset.UnixEpoch, mutation.UpdatedAt));
|
||||
Assert.All(mutations.Items, mutation =>
|
||||
Assert.StartsWith("reconcile:", mutation.DeliveryId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Durable_job_survives_scope_restart_and_rejects_stale_fence()
|
||||
{
|
||||
var database = $"reconciliation-{Guid.NewGuid()}";
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(database)
|
||||
.Options;
|
||||
Guid runId;
|
||||
Guid fence;
|
||||
await using (var context = new ApplicationDbContext(options))
|
||||
{
|
||||
var idProperty = context.Model
|
||||
.FindEntityType(typeof(WorkOrderReconciliationJob))!
|
||||
.FindProperty(nameof(WorkOrderReconciliationJob.Id))!;
|
||||
Assert.Equal(
|
||||
Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never,
|
||||
idProperty.ValueGenerated);
|
||||
var data = new WorkOrderReconciliationDataService(context);
|
||||
var queued = await data.EnqueueAsync(
|
||||
"admin",
|
||||
DateTimeOffset.UtcNow,
|
||||
CancellationToken.None);
|
||||
runId = queued.RunId!.Value;
|
||||
}
|
||||
|
||||
await using (var context = new ApplicationDbContext(options))
|
||||
{
|
||||
var data = new WorkOrderReconciliationDataService(context);
|
||||
var lease = await data.TryAcquirePendingAsync(
|
||||
DateTimeOffset.UtcNow,
|
||||
TimeSpan.FromMinutes(2),
|
||||
CancellationToken.None);
|
||||
fence = lease!.FenceToken;
|
||||
Assert.Equal(runId, lease.RunId);
|
||||
await data.CompleteAsync(
|
||||
runId,
|
||||
Guid.NewGuid(),
|
||||
DateTimeOffset.UtcNow,
|
||||
99,
|
||||
99,
|
||||
CancellationToken.None);
|
||||
Assert.Equal("Running", (await data.GetStatusAsync(CancellationToken.None)).State);
|
||||
await data.CompleteAsync(
|
||||
runId,
|
||||
fence,
|
||||
DateTimeOffset.UtcNow,
|
||||
2,
|
||||
3,
|
||||
CancellationToken.None);
|
||||
var status = await data.GetStatusAsync(CancellationToken.None);
|
||||
Assert.Equal("Succeeded", status.State);
|
||||
Assert.Equal(2, status.WorkOrdersProcessed);
|
||||
Assert.Equal(3, status.CommentsProcessed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Equal_timestamp_converges_to_lexicographically_greater_version()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase($"convergence-{Guid.NewGuid()}")
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
var data = new WorkOrderWebhookDataService(context);
|
||||
var time = DateTimeOffset.Parse("2026-07-24T12:00:00Z");
|
||||
|
||||
await data.ApplyAsync(Mutation("d1", "aaa", "older-tie", time), CancellationToken.None);
|
||||
await data.ApplyAsync(Mutation("d2", "zzz", "winner", time), CancellationToken.None);
|
||||
await data.ApplyAsync(Mutation("d3", "aaa", "loser-replay", time), CancellationToken.None);
|
||||
|
||||
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
||||
}
|
||||
|
||||
private static WorkOrderReconciliationService Create(
|
||||
IProcurementWorkOrderClient client,
|
||||
IWorkOrderWebhookDataService workOrders,
|
||||
IWorkOrderReconciliationDataService jobs) =>
|
||||
new(
|
||||
client,
|
||||
workOrders,
|
||||
jobs,
|
||||
new TestOptions(new WorkOrderReconciliationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxPages = 10,
|
||||
PageSize = 100,
|
||||
LeaseSeconds = 120
|
||||
}),
|
||||
TimeProvider.System,
|
||||
NullLogger<WorkOrderReconciliationService>.Instance);
|
||||
|
||||
private static ProcurementWorkOrder WorkOrder(string id, string status) =>
|
||||
new()
|
||||
{
|
||||
WorkOrderId = id,
|
||||
WoStatus = status,
|
||||
Description = $"Mock {id}",
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-07-24T12:00:00Z")
|
||||
};
|
||||
|
||||
private static ProcurementWorkOrderComment Comment(string workOrderId, string commentId) =>
|
||||
new()
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
CommentId = commentId,
|
||||
Text = "Mock comment",
|
||||
IngestedAt = DateTimeOffset.Parse("2026-07-24T12:01:00Z")
|
||||
};
|
||||
|
||||
private static WorkOrderWebhookMutation Mutation(
|
||||
string delivery,
|
||||
string versionHash,
|
||||
string title,
|
||||
DateTimeOffset updatedAt) =>
|
||||
new()
|
||||
{
|
||||
DeliveryId = delivery,
|
||||
EventType = "work_order.updated",
|
||||
OccurredAt = updatedAt,
|
||||
UpdatedAt = updatedAt,
|
||||
ProcessedAt = updatedAt,
|
||||
BodySha256 = delivery,
|
||||
VersionHash = versionHash,
|
||||
ExternalWorkOrderId = "123",
|
||||
WorkerOrderNumber = "123",
|
||||
Source = "procurement",
|
||||
IsStateEvent = true,
|
||||
Title = title
|
||||
};
|
||||
|
||||
private sealed class MockClient : IProcurementWorkOrderClient
|
||||
{
|
||||
private readonly Queue<ProcurementPage<ProcurementWorkOrder>> _workOrders;
|
||||
private readonly Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>> _comments;
|
||||
|
||||
public MockClient(
|
||||
IEnumerable<ProcurementPage<ProcurementWorkOrder>> workOrders,
|
||||
Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>> comments)
|
||||
{
|
||||
_workOrders = new Queue<ProcurementPage<ProcurementWorkOrder>>(workOrders);
|
||||
_comments = comments;
|
||||
}
|
||||
|
||||
public Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
|
||||
string? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_workOrders.Dequeue());
|
||||
|
||||
public Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
|
||||
string workOrderId,
|
||||
string? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_comments.TryGetValue(workOrderId, out var pages)
|
||||
? pages.Dequeue()
|
||||
: new ProcurementPage<ProcurementWorkOrderComment>(
|
||||
Array.Empty<ProcurementWorkOrderComment>(),
|
||||
null));
|
||||
}
|
||||
|
||||
private sealed class RecordingWorkOrders : IWorkOrderWebhookDataService
|
||||
{
|
||||
public List<WorkOrderWebhookMutation> Items { get; } = new();
|
||||
|
||||
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
||||
WorkOrderWebhookMutation mutation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Items.Add(mutation);
|
||||
return Task.FromResult(new WorkOrderWebhookPersistenceResult(
|
||||
WorkOrderWebhookPersistenceStatus.Applied));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingJobs : IWorkOrderReconciliationDataService
|
||||
{
|
||||
private readonly Guid _run = Guid.NewGuid();
|
||||
private readonly Guid _fence = Guid.NewGuid();
|
||||
public int CompletedWorkOrders { get; private set; }
|
||||
public int CompletedComments { get; private set; }
|
||||
public int RenewCount { get; private set; }
|
||||
public bool LoseLeaseOnRenewal { get; init; }
|
||||
|
||||
public Task<ReconciliationJobSnapshot> EnqueueAsync(
|
||||
string reason,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(Snapshot("Pending"));
|
||||
|
||||
public Task<ReconciliationLease?> TryAcquirePendingAsync(
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<ReconciliationLease?>(new(_run, _fence));
|
||||
|
||||
public Task<ReconciliationJobSnapshot> GetStatusAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(Snapshot("Pending"));
|
||||
|
||||
public Task<bool> RenewLeaseAsync(
|
||||
Guid runId,
|
||||
Guid fenceToken,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RenewCount++;
|
||||
return Task.FromResult(!LoseLeaseOnRenewal);
|
||||
}
|
||||
|
||||
public Task CompleteAsync(
|
||||
Guid runId,
|
||||
Guid fenceToken,
|
||||
DateTimeOffset completedAt,
|
||||
int workOrders,
|
||||
int comments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CompletedWorkOrders = workOrders;
|
||||
CompletedComments = comments;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task FailAsync(
|
||||
Guid runId,
|
||||
Guid fenceToken,
|
||||
DateTimeOffset completedAt,
|
||||
string errorCode,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
private ReconciliationJobSnapshot Snapshot(string state) =>
|
||||
new(_run, state, "test", null, null, null, 0, 0, null);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,509 +0,0 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class WorkOrderWebhookServiceTests
|
||||
{
|
||||
private static readonly byte[] Secret = Encoding.UTF8.GetBytes("test-secret-with-enough-entropy");
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_signature_is_parsed_and_forwarded_with_cancellation()
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var body = ValidBody();
|
||||
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||
cts.Token);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookStatus.Applied, result.Status);
|
||||
Assert.NotNull(data.Mutation);
|
||||
Assert.Equal("delivery-1", data.Mutation.DeliveryId);
|
||||
Assert.Equal("WO-123", data.Mutation.WorkerOrderNumber);
|
||||
Assert.Equal(cts.Token, data.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authentic_wire_source_is_accepted_and_persisted_as_canonical()
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var body = ValidBody();
|
||||
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookStatus.Applied, result.Status);
|
||||
Assert.NotNull(data.Mutation);
|
||||
Assert.Equal("procurement", data.Mutation!.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_procurement_wire_source_is_rejected_before_persistence()
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var body = ValidBody(source: "procurement");
|
||||
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, result.Status);
|
||||
Assert.Null(data.Mutation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_external_id_is_preserved_without_internal_number_normalization()
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var externalId = "WO-PROCUREMENT-2026-000000000123";
|
||||
var body = ValidBody(workOrderId: externalId);
|
||||
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookStatus.Applied, result.Status);
|
||||
Assert.Equal(externalId, data.Mutation!.ExternalWorkOrderId);
|
||||
Assert.Equal(externalId, data.Mutation.WorkerOrderNumber);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-300, WorkOrderWebhookStatus.Applied)]
|
||||
[InlineData(300, WorkOrderWebhookStatus.Applied)]
|
||||
[InlineData(-301, WorkOrderWebhookStatus.Unauthorized)]
|
||||
[InlineData(301, WorkOrderWebhookStatus.Unauthorized)]
|
||||
public async Task Timestamp_boundaries_are_enforced(
|
||||
int offsetSeconds,
|
||||
WorkOrderWebhookStatus expected)
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var body = ValidBody();
|
||||
var timestamp = Now.AddSeconds(offsetSeconds).ToUnixTimeSeconds().ToString();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(expected, result.Status);
|
||||
Assert.Equal(expected == WorkOrderWebhookStatus.Applied, data.Mutation != null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tampered_body_is_rejected_before_parse_or_persistence()
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var signedBody = ValidBody();
|
||||
var tampered = Encoding.UTF8.GetBytes("{not-json");
|
||||
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(
|
||||
timestamp,
|
||||
"current",
|
||||
Sign(timestamp, signedBody),
|
||||
tampered),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookStatus.Unauthorized, result.Status);
|
||||
Assert.Null(data.Mutation);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("v1=xyz")]
|
||||
public async Task Missing_or_malformed_signature_is_uniformly_unauthorized(string? signature)
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var body = ValidBody();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(
|
||||
Now.ToUnixTimeSeconds().ToString(),
|
||||
"current",
|
||||
signature,
|
||||
body),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookStatus.Unauthorized, result.Status);
|
||||
Assert.Null(data.Mutation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signed_malformed_known_type_is_invalid_without_persistence()
|
||||
{
|
||||
var data = new RecordingDataService();
|
||||
var service = CreateService(data);
|
||||
var body = Encoding.UTF8.GetBytes("""
|
||||
{"schema_version":"one","delivery_id":"d","event_type":"work_order.created",
|
||||
"occurred_at":"2026-07-24T12:00:00Z","source":"procurement-ingest/workorder-shoc-emitter","replay":false,
|
||||
"data":{"work_order_id":"123"}}
|
||||
""");
|
||||
var timestamp = Now.ToUnixTimeSeconds().ToString();
|
||||
|
||||
var result = await service.ProcessAsync(
|
||||
new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, result.Status);
|
||||
Assert.Null(data.Mutation);
|
||||
}
|
||||
|
||||
private static WorkOrderWebhookService CreateService(RecordingDataService data) =>
|
||||
new(
|
||||
new StaticSecretProvider(),
|
||||
data,
|
||||
new StaticOptionsMonitor<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
|
||||
{
|
||||
Enabled = true,
|
||||
AllowedClockSkewSeconds = 300,
|
||||
SecretId = "workorder-ingest/shoc-webhook-hmac"
|
||||
}),
|
||||
new FixedTimeProvider(Now),
|
||||
NullLogger<WorkOrderWebhookService>.Instance);
|
||||
|
||||
private static byte[] ValidBody(
|
||||
string deliveryId = "delivery-1",
|
||||
string workOrderId = "WO-123",
|
||||
string source = "procurement-ingest/workorder-shoc-emitter") =>
|
||||
Encoding.UTF8.GetBytes(
|
||||
"{\"schema_version\":1,\"delivery_id\":\"" + deliveryId
|
||||
+ "\",\"event_type\":\"work_order.created\","
|
||||
+ "\"occurred_at\":\"2026-07-24T11:59:00Z\",\"source\":\"" + source + "\","
|
||||
+ "\"replay\":false,\"data\":{\"work_order_id\":\"" + workOrderId + "\","
|
||||
+ "\"title\":\"Leaking pipe\",\"wo_status\":\"new\",\"severity\":\"2\"}}");
|
||||
|
||||
private static string Sign(string timestamp, byte[] body)
|
||||
{
|
||||
var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
|
||||
var signed = new byte[prefix.Length + body.Length];
|
||||
prefix.CopyTo(signed, 0);
|
||||
body.CopyTo(signed, prefix.Length);
|
||||
return "v1=" + Convert.ToHexString(HMACSHA256.HashData(Secret, signed)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class StaticSecretProvider : IWorkOrderWebhookSecretProvider
|
||||
{
|
||||
public Task<WorkOrderWebhookSecretResult> GetSecretAsync(
|
||||
string keyId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(keyId == "current"
|
||||
? new WorkOrderWebhookSecretResult(
|
||||
WorkOrderWebhookSecretStatus.Found,
|
||||
(byte[])Secret.Clone())
|
||||
: new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey));
|
||||
}
|
||||
|
||||
private sealed class RecordingDataService : IWorkOrderWebhookDataService
|
||||
{
|
||||
public WorkOrderWebhookMutation? Mutation { get; private set; }
|
||||
public CancellationToken CancellationToken { get; private set; }
|
||||
|
||||
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
||||
WorkOrderWebhookMutation mutation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Mutation = mutation;
|
||||
CancellationToken = cancellationToken;
|
||||
return Task.FromResult(new WorkOrderWebhookPersistenceResult(
|
||||
WorkOrderWebhookPersistenceStatus.Applied));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WorkOrderWebhookDataServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task State_comment_staleness_and_delivery_deduplication_are_atomic()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var context = new CountingDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
var data = new WorkOrderWebhookDataService(context);
|
||||
|
||||
var comment = Mutation(
|
||||
"comment-delivery",
|
||||
"hash-comment",
|
||||
"work_order.comment_added",
|
||||
new DateTimeOffset(2026, 7, 24, 10, 0, 0, TimeSpan.Zero),
|
||||
commentId: "comment-1");
|
||||
var commentResult = await data.ApplyAsync(comment, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WorkOrderWebhookPersistenceStatus.Applied, commentResult.Status);
|
||||
var skeleton = await context.workOrders.SingleAsync();
|
||||
Assert.Equal("123", skeleton.ExternalWorkOrderId);
|
||||
Assert.Equal("00000000001", skeleton.InternalWONumber);
|
||||
Assert.Equal("00000000123", skeleton.WorkerOrderNumber);
|
||||
Assert.Equal(skeleton.Id, (await context.Comments.SingleAsync()).WorkerOrderId);
|
||||
|
||||
var state = Mutation(
|
||||
"state-delivery",
|
||||
"hash-state",
|
||||
"work_order.updated",
|
||||
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero),
|
||||
title: "Current title");
|
||||
await data.ApplyAsync(state, CancellationToken.None);
|
||||
|
||||
var stale = Mutation(
|
||||
"stale-delivery",
|
||||
"hash-stale",
|
||||
"work_order.updated",
|
||||
new DateTimeOffset(2026, 7, 24, 11, 0, 0, TimeSpan.Zero),
|
||||
title: "Stale title");
|
||||
var staleResult = await data.ApplyAsync(stale, CancellationToken.None);
|
||||
|
||||
Assert.True(staleResult.StateMutationSkipped);
|
||||
Assert.Equal("Current title", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
||||
Assert.Equal(3, await context.WorkOrderWebhookDeliveries.CountAsync());
|
||||
|
||||
var duplicate = await data.ApplyAsync(state, CancellationToken.None);
|
||||
Assert.Equal(WorkOrderWebhookPersistenceStatus.Duplicate, duplicate.Status);
|
||||
|
||||
var conflict = await data.ApplyAsync(
|
||||
state with { BodySha256 = "different-hash" },
|
||||
CancellationToken.None);
|
||||
Assert.Equal(WorkOrderWebhookPersistenceStatus.HashConflict, conflict.Status);
|
||||
Assert.Equal(3, context.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_event_forces_cancelled_status()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
var data = new WorkOrderWebhookDataService(context);
|
||||
var mutation = Mutation(
|
||||
"cancel-delivery",
|
||||
"hash",
|
||||
"work_order.cancelled",
|
||||
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)) with
|
||||
{
|
||||
IsCancelled = true,
|
||||
Status = "Open",
|
||||
LifecycleStatus = LifecycleStatus.Canceled
|
||||
};
|
||||
|
||||
await data.ApplyAsync(mutation, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Cancelled", (await context.workOrders.SingleAsync()).Status);
|
||||
Assert.Equal(LifecycleStatus.Canceled, (await context.workOrders.SingleAsync()).LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unmapped_imported_status_preserves_existing_lifecycle_status()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
ExternalWorkOrderId = "123",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var data = new WorkOrderWebhookDataService(context);
|
||||
|
||||
await data.ApplyAsync(
|
||||
Mutation(
|
||||
"unmapped-status",
|
||||
"hash",
|
||||
"work_order.updated",
|
||||
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, (await context.workOrders.SingleAsync()).LifecycleStatus);
|
||||
}
|
||||
|
||||
private static WorkOrderWebhookMutation Mutation(
|
||||
string deliveryId,
|
||||
string bodyHash,
|
||||
string eventType,
|
||||
DateTimeOffset occurredAt,
|
||||
string? title = null,
|
||||
string? commentId = null) =>
|
||||
new()
|
||||
{
|
||||
DeliveryId = deliveryId,
|
||||
EventType = eventType,
|
||||
OccurredAt = occurredAt,
|
||||
UpdatedAt = occurredAt,
|
||||
ProcessedAt = occurredAt.AddMinutes(1),
|
||||
BodySha256 = bodyHash,
|
||||
VersionHash = bodyHash,
|
||||
ExternalWorkOrderId = "123",
|
||||
WorkerOrderNumber = "00000000123",
|
||||
Source = "procurement",
|
||||
IsStateEvent = eventType != "work_order.comment_added",
|
||||
Title = title,
|
||||
Status = "Open",
|
||||
CommentId = commentId,
|
||||
CommentText = commentId == null ? null : "A comment"
|
||||
};
|
||||
|
||||
private sealed class CountingDbContext : ApplicationDbContext
|
||||
{
|
||||
public CountingDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
SaveCount++;
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WorkOrderWebhookControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("text/plain")]
|
||||
[InlineData("application/xml")]
|
||||
public async Task Unsupported_media_type_is_rejected_before_service(string contentType)
|
||||
{
|
||||
var fake = new ControllerService();
|
||||
var controller = CreateController(fake, Encoding.UTF8.GetBytes("{}"), contentType);
|
||||
|
||||
var result = await controller.Receive(CancellationToken.None);
|
||||
|
||||
Assert.Equal(StatusCodes.Status415UnsupportedMediaType, Assert.IsType<StatusCodeResult>(result).StatusCode);
|
||||
Assert.False(fake.Called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Streaming_body_over_limit_is_rejected_before_service()
|
||||
{
|
||||
var fake = new ControllerService { MaximumBodyBytes = 4 };
|
||||
var controller = CreateController(
|
||||
fake,
|
||||
Encoding.UTF8.GetBytes("12345"),
|
||||
"application/json",
|
||||
contentLength: null);
|
||||
|
||||
var result = await controller.Receive(CancellationToken.None);
|
||||
|
||||
Assert.Equal(StatusCodes.Status413PayloadTooLarge, Assert.IsType<StatusCodeResult>(result).StatusCode);
|
||||
Assert.False(fake.Called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_request_forwards_exact_body_and_cancellation()
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes("{\"x\":1}");
|
||||
var fake = new ControllerService();
|
||||
var controller = CreateController(fake, body, "application/json; charset=utf-8");
|
||||
controller.Request.Headers["X-SH-Timestamp"] = "1";
|
||||
controller.Request.Headers["X-SH-Key-Id"] = "key";
|
||||
controller.Request.Headers["X-SH-Signature"] = "sig";
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var result = await controller.Receive(cts.Token);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.Equal(body, fake.Request!.Body);
|
||||
Assert.Equal(cts.Token, fake.CancellationToken);
|
||||
}
|
||||
|
||||
private static WorkOrderWebhookController CreateController(
|
||||
ControllerService service,
|
||||
byte[] body,
|
||||
string contentType,
|
||||
long? contentLength = 0)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Body = new MemoryStream(body);
|
||||
context.Request.ContentType = contentType;
|
||||
context.Request.ContentLength = contentLength == 0 ? body.Length : contentLength;
|
||||
return new WorkOrderWebhookController(service)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = context }
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ControllerService : IWorkOrderWebhookService
|
||||
{
|
||||
public int MaximumBodyBytes { get; set; } = 1_048_576;
|
||||
public bool Called { get; private set; }
|
||||
public WorkOrderWebhookRequest? Request { get; private set; }
|
||||
public CancellationToken CancellationToken { get; private set; }
|
||||
|
||||
public Task<WorkOrderWebhookResult> ProcessAsync(
|
||||
WorkOrderWebhookRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Called = true;
|
||||
Request = request;
|
||||
CancellationToken = cancellationToken;
|
||||
return Task.FromResult(new WorkOrderWebhookResult(WorkOrderWebhookStatus.Applied));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
|
||||
internal sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
CurrentValue = value;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
public T Get(string? name) => CurrentValue;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
# shoc-backend CDK (dev deployment IAM)
|
||||
|
||||
This CDK v2 app owns exactly one thing in the `shoc-backend` AWS account
|
||||
(`396287094661`, `us-east-1`): the **GitHub OIDC deploy role** used by the
|
||||
`dev` deployment workflow in `.github/workflows/deploy.yml`.
|
||||
|
||||
## Ownership boundary (deliberate)
|
||||
|
||||
CDK owns:
|
||||
|
||||
- The IAM role `githubdeploy-shoc-backend-dev`.
|
||||
- Its OIDC trust relationship to
|
||||
`arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com`
|
||||
scoped to `repo:Sea-Haven-Industries/shoc-backend:environment:dev`.
|
||||
- Its least-privilege inline permissions policy.
|
||||
|
||||
CDK does **not** own, create, import, replace, or modify any of the following.
|
||||
They are referenced by exact identifier only and remain owned by their original
|
||||
provisioning path:
|
||||
|
||||
- Elastic Beanstalk application `shoc-backend`
|
||||
- Elastic Beanstalk environment `shoc-backend-dev`
|
||||
- DNS, VPC, EC2, RDS, and existing service/instance roles
|
||||
- S3 bucket `elasticbeanstalk-us-east-1-396287094661`
|
||||
- Environment configuration / option settings
|
||||
- Database schema (migrations are applied by Elastic Beanstalk at deploy time,
|
||||
not by CDK)
|
||||
|
||||
The role is retained on stack deletion (`DeletionPolicy=Retain`,
|
||||
`UpdateReplacePolicy=Retain`) so an accidental teardown cannot orphan the trust
|
||||
or lock out deployments.
|
||||
|
||||
## Least-privilege policy summary
|
||||
|
||||
The role grants only:
|
||||
|
||||
- The three read-only Elastic Beanstalk actions used by deploy, wait, and
|
||||
rollback (`DescribeApplicationVersions`, `DescribeEnvironments`, and
|
||||
`DescribeEvents`). These use `Resource: "*"` because Elastic Beanstalk
|
||||
describe actions are not reliably constrained by resource ARN.
|
||||
- `elasticbeanstalk:CreateApplicationVersion` on application `shoc-backend`.
|
||||
- `elasticbeanstalk:UpdateEnvironment` on environment `shoc-backend-dev` only.
|
||||
- `s3:ListBucket` and `s3:GetBucketLocation` on
|
||||
`elasticbeanstalk-us-east-1-396287094661` (the official action's
|
||||
ownership-safe bucket checks), `s3:GetBucketPolicy` for the policy inspection
|
||||
observed in attempt 11 of run `30448885838`, plus `s3:CreateBucket` and
|
||||
`s3:PutBucketOwnershipControls` on the same bucket-level ARN. Under the
|
||||
`shoc-backend/` object prefix only:
|
||||
`s3:PutObject`, `s3:GetObject`, and `s3:GetObjectVersion`, which the pinned
|
||||
official deployment action requires to validate the
|
||||
`CreateApplicationVersion` source bundle after upload.
|
||||
- `s3:PutObject`, `s3:GetObject`, `s3:GetObjectVersionAcl`,
|
||||
`s3:PutObjectVersionAcl`, and `s3:DeleteObject` on only
|
||||
`elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_versions/shoc-backend/*`.
|
||||
Elastic Beanstalk copies each uploaded source bundle into this
|
||||
environment-specific runtime prefix during `UpdateEnvironment`, verifies it
|
||||
with `HeadObject` (authorized by `s3:GetObject`), and removes the temporary
|
||||
copy after the version is registered. Attempts 1 through 4 of run
|
||||
`30448885838` exposed the exact source, destination, cleanup, and verification
|
||||
operations after the earlier ACL denial was resolved. CloudTrail recorded
|
||||
the exact `s3:GetObject` denial on attempt 4; attempt 6 then exposed the
|
||||
version-specific ACL read performed on the copied object; attempt 7 exposed
|
||||
the matching version-ACL write. The grant does not cover another
|
||||
environment, another application, source bundles, object content versions,
|
||||
non-version ACL mutation, tags, or retention.
|
||||
- `s3:PutObject` on only the two embedded-extension prefixes
|
||||
`elasticbeanstalk-us-east-1-396287094661/resources/_runtime/_embedded_extensions/shoc-backend/*`
|
||||
and
|
||||
`elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_embedded_extensions/shoc-backend/*`.
|
||||
After the runtime bundle copy and version-ACL operations succeeded, attempt 8
|
||||
of run `30448885838` showed Elastic Beanstalk materializing the application's
|
||||
embedded-extension manifest at the application-specific shared prefix.
|
||||
Attempt 9 then showed the matching write into the exact dev-environment
|
||||
prefix. CloudTrail recorded both denied actions and object ARNs. The grant
|
||||
does not include reads, deletes, ACL mutation, another application,
|
||||
another environment, or another bucket.
|
||||
- `s3:GetObject` on only the environment-specific embedded-extension prefix
|
||||
above. Attempt 10 showed that Elastic Beanstalk verifies the materialized
|
||||
environment copy with `HeadObject`, which S3 authorizes through
|
||||
`s3:GetObject`. The shared embedded-extension prefix remains write-only.
|
||||
- `s3:GetObject` and `s3:PutObject` on only
|
||||
`elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/versions/*`.
|
||||
Attempt 12 showed Elastic Beanstalk reading the previous environment version
|
||||
manifest and writing its replacement under this exact dev-environment
|
||||
runtime prefix. The grant excludes deletes, ACL mutation, other environments,
|
||||
and application bundle content.
|
||||
- `s3:GetObjectAcl` on objects under the service-wide
|
||||
`arn:aws:s3:::elasticbeanstalk-*/*` namespace. AWS Support case
|
||||
`178526484500047` confirmed that `UpdateEnvironment` uses the initiating
|
||||
role to inspect objects in AWS-owned Elastic Beanstalk buckets, not only the
|
||||
account-owned source-bundle bucket. The wildcard is limited to one read-only
|
||||
ACL action and the Elastic Beanstalk bucket namespace; it grants no object
|
||||
content read, write, delete, bucket-management, IAM, or `PassRole`
|
||||
capability.
|
||||
|
||||
`s3:CreateBucket` is part of the pinned
|
||||
`aws-actions/aws-elastic-beanstalk-deploy@cfad3e5e...` (v1.0.6) IAM
|
||||
contract even though the workflow sets
|
||||
`create-s3-bucket-if-not-exists: "false"`. That input prevents the
|
||||
action's explicit bucket-creation helper; it does not remove the permission
|
||||
required by the subsequent Elastic Beanstalk update path. A live deployment
|
||||
confirmed this boundary: `CreateApplicationVersion` succeeded, then
|
||||
`UpdateEnvironment` was denied because the caller lacked
|
||||
`s3:CreateBucket` on the service bucket. The permission is scoped to that
|
||||
exact bucket-level ARN only (no object prefix, no wildcard resource), so it
|
||||
cannot create any other bucket.
|
||||
|
||||
`s3:PutBucketOwnershipControls` was added after a second live deployment
|
||||
(run 30375409934) failed at `UpdateEnvironment` with `AccessDenied` for
|
||||
`s3:PutBucketOwnershipControls` on the same service bucket. That call is
|
||||
emitted by Elastic Beanstalk's `UpdateEnvironment` path after the source
|
||||
bundle upload succeeds; AWS classifies it as a bucket-level permission, so
|
||||
it is scoped to the same exact bucket-level ARN (no object prefix, no
|
||||
wildcard resource). It does not widen object-prefix permissions, does not
|
||||
grant `PutBucketPolicy`, `PutBucketPublicAccessBlock`, or any object-level write,
|
||||
and does not change `create-s3-bucket-if-not-exists: "false"`.
|
||||
|
||||
`s3:GetBucketLocation` was added after CloudTrail showed that run
|
||||
`30375409934` attempt 4 invoked it as
|
||||
`githubdeploy-shoc-backend-dev/GitHubActions` and was denied. It is scoped to
|
||||
the exact bucket-level ARN and grants no object access.
|
||||
|
||||
- The six CloudFormation discovery calls observed across the failed OIDC and
|
||||
successful administrator deployments (`DescribeStackEvents`,
|
||||
`DescribeStackResource`, `DescribeStackResources`, `DescribeStacks`,
|
||||
`GetTemplate`, and `ListStackResources`) on the Elastic Beanstalk-managed
|
||||
stack `awseb-e-hehnrqjjrt-stack`, scoped to
|
||||
`arn:aws:cloudformation:us-east-1:396287094661:stack/awseb-e-hehnrqjjrt-stack/*`.
|
||||
These read-only calls are emitted by Elastic Beanstalk's
|
||||
`UpdateEnvironment` path under the GitHub deploy role. `GetTemplate` was
|
||||
added after run `30375409934` attempt 2 advanced past the S3
|
||||
ownership-controls step and was denied on the EB-managed stack instance
|
||||
`awseb-e-hehnrqjjrt-stack/112f77c0-7718-11f1-a1a9-0e48750aef13`.
|
||||
CloudTrail then showed attempt 4 denied `DescribeStackResources` and
|
||||
`ListStackResources` on that same stack instance.
|
||||
CloudFormation stack ARNs carry a random GUID instance suffix, so the
|
||||
permission is scoped to that one stack-name prefix (`/*`) rather than a
|
||||
single instance ARN. The statement grants no CloudFormation mutation, no
|
||||
`Resource: "*"`, and no access to any other stack. CDK does not own or
|
||||
mutate that stack; it is owned by Elastic Beanstalk and referenced by
|
||||
identifier only.
|
||||
|
||||
- `ec2:DescribeAvailabilityZones`, `ec2:DescribeImages`, and
|
||||
`ec2:DescribeSubnets` as read-only account-level discovery queries.
|
||||
CloudTrail identified the GitHub deploy role as the caller during run
|
||||
`30375409934`; attempt 5 confirmed the first two denials after
|
||||
`DescribeSubnets` was allowed. EC2 does not support resource-level
|
||||
constraints for these Describe actions, so IAM requires `Resource: "*"`.
|
||||
No EC2 mutation action is granted.
|
||||
- The Auto Scaling discovery calls `DescribeAutoScalingGroups` and
|
||||
`DescribeScalingActivities` on `Resource: "*"` plus
|
||||
`PutNotificationConfiguration`, `ResumeProcesses`, and `SuspendProcesses`
|
||||
on only Auto Scaling groups whose name starts with
|
||||
`awseb-e-hehnrqjjrt-stack-`. These are the exact calls recorded during the
|
||||
successful administrator deployment. AWS supports resource-level
|
||||
constraints for all three mutations, so replacement ASGs remain covered
|
||||
without granting access to another environment.
|
||||
It grants **no** IAM mutation or `PassRole`, **no** RDS / Secrets Manager
|
||||
access, no EC2 mutation, and **no** administrator policy. The only non-EB/S3
|
||||
mutations are the three deployment-process Auto Scaling calls, restricted to
|
||||
this environment's ASG name pattern. There are no wildcard mutation surfaces;
|
||||
the only service-wide object grant is read-only ACL metadata.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node >= 22.22.1 and npm.
|
||||
- AWS credentials authorized to create/inspect CloudFormation, IAM roles, and
|
||||
trust policies in account `396287094661`.
|
||||
- The GitHub OIDC provider
|
||||
`arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com`
|
||||
must already exist in the account (created once, outside this stack).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm ci # install pinned dependencies
|
||||
npm run build # type-check / compile to dist/
|
||||
npm run synth # synthesize the CloudFormation template
|
||||
npm run diff # diff deployed stack vs local (requires AWS)
|
||||
npm run deploy # deploy the stack (requires AWS)
|
||||
```
|
||||
|
||||
All commands run from `infra/cdk/`.
|
||||
|
||||
## CI integration
|
||||
|
||||
`npm run synth` is the deterministic local/CI validation. After synth, inspect
|
||||
`cdk.out/shoc-backend-deploy-dev.template.json` and verify the synthesized
|
||||
`AWS::IAM::Role`:
|
||||
|
||||
- Trust policy `StringEquals` matches the exact audience and subject above.
|
||||
- The inline policy contains no `Resource: "*"` mutation action and no service
|
||||
outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` /
|
||||
`elasticloadbalancing` / `autoscaling`. CloudFormation discovery and
|
||||
mutations are limited to the single EB-managed stack prefix. EC2, Elastic
|
||||
Load Balancing, and Auto Scaling discovery use `Resource: "*"` only where the
|
||||
IAM resource model requires it; Auto Scaling mutations are limited to this
|
||||
environment's ASG name pattern.
|
||||
|
||||
The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must
|
||||
hold the ARN output by this stack (`GithubDeployRoleArn`).
|
||||
|
||||
The previous OIDC deployment remained fail-closed after Elastic Beanstalk
|
||||
reported a generic `s3:GetObjectAcl` denial outside the account-owned source
|
||||
prefix. AWS Support case `178526484500047` subsequently confirmed that
|
||||
`UpdateEnvironment` checks objects in AWS-owned Elastic Beanstalk buckets
|
||||
using the initiating role and requires the service-wide
|
||||
`elasticbeanstalk-*` bucket/object namespaces.
|
||||
|
||||
The July 30 deployment of backend PR #41 then reached `UpdateEnvironment` and
|
||||
failed on `ec2:DescribeVpcs`. Elastic Beanstalk performs this read-only network
|
||||
discovery using the initiating role, so the CDK policy includes that action
|
||||
alongside the existing EC2 describe permissions. It remains resource `*`
|
||||
because `DescribeVpcs` does not support resource-level permissions.
|
||||
|
||||
Successive exact reruns then reached S3 cleanup, the delegated CloudFormation
|
||||
update, and the CloudFormation template fetch. The observed failures were
|
||||
`s3:DeleteObject`, `cloudformation:UpdateStack`, and finally an opaque
|
||||
CloudFormation `S3 error: Access Denied` after narrower object reads had been
|
||||
added. Because AWS does not expose the AWS-owned bucket/key or exact internal
|
||||
S3 read in that final error, the CDK now uses AWS Support's authoritative
|
||||
UpdateEnvironment S3 set:
|
||||
|
||||
- `s3:Delete*`, `s3:Get*`, and `s3:Put*` on
|
||||
`arn:aws:s3:::elasticbeanstalk-*/*`.
|
||||
- `s3:GetBucket*`, `s3:ListBucket`, `s3:PutBucketPolicy`,
|
||||
`s3:PutBucketPublicAccessBlock`, and `s3:PutBucketOwnershipControls` on
|
||||
`arn:aws:s3:::elasticbeanstalk-*`.
|
||||
|
||||
`s3:CreateBucket` remains excluded because this workflow targets an existing
|
||||
application/environment and explicitly disables bucket creation. No S3 access
|
||||
is granted to non-Elastic-Beanstalk bucket names. The CloudFormation mutation
|
||||
remains limited to the single existing `shoc-backend-dev` managed stack ARN; it
|
||||
cannot create stacks or update another stack.
|
||||
|
||||
The next rerun cleared S3 and then required the read-only
|
||||
`elasticloadbalancing:DescribeLoadBalancers` discovery action. Its failed
|
||||
managed-stack update also required `cloudformation:CancelUpdateStack`; the
|
||||
cancel action is scoped to the same single stack ARN as `UpdateStack`.
|
||||
|
||||
The subsequent rerun progressed into Auto Scaling and required
|
||||
`autoscaling:DescribeLaunchConfigurations`. Because Elastic Beanstalk's
|
||||
managed update workflow performs variable resource discovery, the role follows
|
||||
the documented read-only discovery families for EC2, Elastic Load Balancing,
|
||||
and Auto Scaling (`Describe*`). These grants expose metadata across the account
|
||||
but do not authorize any mutation; write actions remain separately scoped.
|
||||
|
||||
The pinned deployment action can return success after Elastic Beanstalk emits a
|
||||
fatal deployment event. The following workflow step therefore verifies that
|
||||
the exact immutable version label is active and healthy before smoke testing.
|
||||
Any mismatch fails and invokes rollback. This guard prevents false success; it
|
||||
does not make the unresolved OIDC deployment path release-ready.
|
||||
|
||||
The GitHub `dev` environment is an external release control and must restrict
|
||||
deployments to the `dev` branch. Required reviewers should be configured when
|
||||
the repository plan supports environment reviewers. The workflow also checks
|
||||
the exact branch before requesting an OIDC token.
|
||||
|
||||
## Migration and recovery contract
|
||||
|
||||
The deployment bundle applies pending EF Core migrations before the new
|
||||
application starts. Migrations must therefore use an expand/contract sequence:
|
||||
|
||||
- Expand changes must remain backward compatible with the previously deployed
|
||||
application version.
|
||||
- Destructive contract changes are deployed only after all application versions
|
||||
relying on the old schema have been retired.
|
||||
- A failed deployment restores the previous **application version only**.
|
||||
Database schema is not downgraded, and schema rollback is not claimed.
|
||||
|
||||
This contract preserves the usefulness of application-version recovery without
|
||||
misrepresenting it as a tested database downgrade.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import * as cdk from 'aws-cdk-lib';
|
||||
import { DeployDevStack } from './deploy-dev-stack.js';
|
||||
|
||||
const app = new cdk.App();
|
||||
|
||||
new DeployDevStack(app, 'shoc-backend-deploy-dev', {
|
||||
env: {
|
||||
account: '396287094661',
|
||||
region: 'us-east-1',
|
||||
},
|
||||
terminationProtection: true,
|
||||
tags: {
|
||||
Project: 'shoc-backend',
|
||||
Environment: 'dev',
|
||||
ManagedBy: 'cdk',
|
||||
Component: 'deploy-role',
|
||||
},
|
||||
});
|
||||
|
||||
app.synth();
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"app": "node dist/app.js",
|
||||
"versionReporting": false,
|
||||
"context": {
|
||||
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
import * as cdk from 'aws-cdk-lib';
|
||||
import * as iam from 'aws-cdk-lib/aws-iam';
|
||||
import { Construct } from 'constructs';
|
||||
|
||||
const ACCOUNT_ID = '396287094661';
|
||||
const REGION = 'us-east-1';
|
||||
const APPLICATION_NAME = 'shoc-backend';
|
||||
const ENVIRONMENT_NAME = 'shoc-backend-dev';
|
||||
const ENVIRONMENT_ID = 'e-hehnrqjjrt';
|
||||
const ENVIRONMENT_STACK_NAME = `awseb-${ENVIRONMENT_ID}-stack`;
|
||||
const REPO = 'Sea-Haven-Industries/shoc-backend';
|
||||
|
||||
export class DeployDevStack extends cdk.Stack {
|
||||
constructor(scope: Construct, id: string, props: cdk.StackProps = {}) {
|
||||
super(scope, id, props);
|
||||
|
||||
const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`;
|
||||
const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`;
|
||||
const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`;
|
||||
|
||||
const deployRole = new iam.Role(this, 'GithubDeployRole', {
|
||||
roleName: 'githubdeploy-shoc-backend-dev',
|
||||
description:
|
||||
'Least-privilege GitHub OIDC deploy role for shoc-backend dev. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk.',
|
||||
assumedBy: new iam.FederatedPrincipal(
|
||||
oidcProviderArn,
|
||||
{
|
||||
StringEquals: {
|
||||
'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
|
||||
'token.actions.githubusercontent.com:sub': `repo:${REPO}:environment:dev`,
|
||||
},
|
||||
},
|
||||
'sts:AssumeRoleWithWebIdentity',
|
||||
),
|
||||
});
|
||||
|
||||
deployRole.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
|
||||
const cfnRole = deployRole.node.defaultChild as iam.CfnRole;
|
||||
cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
||||
cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
||||
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: [
|
||||
'autoscaling:Describe*',
|
||||
'ec2:Describe*',
|
||||
'elasticbeanstalk:DescribeEnvironments',
|
||||
'elasticbeanstalk:DescribeApplicationVersions',
|
||||
'elasticbeanstalk:DescribeEvents',
|
||||
'elasticloadbalancing:Describe*',
|
||||
],
|
||||
resources: ['*'],
|
||||
}),
|
||||
);
|
||||
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: ['elasticbeanstalk:CreateApplicationVersion'],
|
||||
resources: [
|
||||
applicationArn,
|
||||
`arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:applicationversion/${APPLICATION_NAME}/*`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: ['elasticbeanstalk:UpdateEnvironment'],
|
||||
resources: [environmentArn],
|
||||
}),
|
||||
);
|
||||
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: [
|
||||
'cloudformation:DescribeStackEvents',
|
||||
'cloudformation:DescribeStackResource',
|
||||
'cloudformation:GetTemplate',
|
||||
'cloudformation:DescribeStackResources',
|
||||
'cloudformation:DescribeStacks',
|
||||
'cloudformation:ListStackResources',
|
||||
'cloudformation:CancelUpdateStack',
|
||||
'cloudformation:UpdateStack',
|
||||
],
|
||||
resources: [
|
||||
`arn:aws:cloudformation:${REGION}:${ACCOUNT_ID}:stack/${ENVIRONMENT_STACK_NAME}/*`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: [
|
||||
'autoscaling:PutNotificationConfiguration',
|
||||
'autoscaling:ResumeProcesses',
|
||||
'autoscaling:SuspendProcesses',
|
||||
],
|
||||
resources: [
|
||||
`arn:aws:autoscaling:${REGION}:${ACCOUNT_ID}:autoScalingGroup:*:autoScalingGroupName/${ENVIRONMENT_STACK_NAME}-*`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: ['s3:Delete*', 's3:Get*', 's3:Put*'],
|
||||
// AWS Support case 178526484500047 confirmed that UpdateEnvironment
|
||||
// reads, writes, versions, ACL-checks, and removes objects in both the
|
||||
// account bucket and AWS-owned Elastic Beanstalk service buckets.
|
||||
resources: ['arn:aws:s3:::elasticbeanstalk-*/*'],
|
||||
}),
|
||||
);
|
||||
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: [
|
||||
's3:GetBucket*',
|
||||
's3:ListBucket',
|
||||
's3:PutBucketOwnershipControls',
|
||||
's3:PutBucketPolicy',
|
||||
's3:PutBucketPublicAccessBlock',
|
||||
],
|
||||
// This is AWS Support's bucket-level UpdateEnvironment set, excluding
|
||||
// CreateBucket because the workflow deploys only to an existing
|
||||
// application/environment and disables bucket creation.
|
||||
resources: ['arn:aws:s3:::elasticbeanstalk-*'],
|
||||
}),
|
||||
);
|
||||
|
||||
new cdk.CfnOutput(this, 'GithubDeployRoleArn', {
|
||||
value: deployRole.roleArn,
|
||||
description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.',
|
||||
exportName: 'shoc-backend-deploy-dev-role-arn',
|
||||
});
|
||||
}
|
||||
}
|
||||
694
infra/cdk/package-lock.json
generated
694
infra/cdk/package-lock.json
generated
|
|
@ -1,694 +0,0 @@
|
|||
{
|
||||
"name": "shoc-backend-cdk",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shoc-backend-cdk",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "2.262.1",
|
||||
"constructs": "10.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.2",
|
||||
"aws-cdk": "2.1134.0",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-awscli-v1": {
|
||||
"version": "2.2.282",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
|
||||
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz",
|
||||
"integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema": {
|
||||
"version": "54.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.14.0.tgz",
|
||||
"integrity": "sha512-JCZCzgp3SuXQVljaKqXnttHzcezEHt9Ag/YipK0XwUFD+Iz2T4jY7gUc3pA25Uq6pzY2n9DvO/nEU++dPXW4Rw==",
|
||||
"bundleDependencies": [
|
||||
"jsonschema",
|
||||
"semver"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1134.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1134.0.tgz",
|
||||
"integrity": "sha512-Fy/g+gdpMpkhAAoCNlZtX0s4mD7q58bHlDV6QfbnTeifmQ5cEge26c5rB+U4qKB/bDudxNuJjQk/mSSk0ysnug==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"cdk": "bin/cdk"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib": {
|
||||
"version": "2.262.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.262.1.tgz",
|
||||
"integrity": "sha512-B6YP4r6ojUZCDhl+qBu/CrWzcipR8sIgshcqYvgw013sghPXmVkYdJ3yuI9+DKML3YLSjQrHy1nGJs+Nqq7JCg==",
|
||||
"bundleDependencies": [
|
||||
"@aws/cloudformation-validate",
|
||||
"@balena/dockerignore",
|
||||
"@aws-cdk/cloud-assembly-api",
|
||||
"case",
|
||||
"fs-extra",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"minimatch",
|
||||
"punycode",
|
||||
"semver",
|
||||
"yaml",
|
||||
"mime-types"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-cdk/asset-awscli-v1": "2.2.282",
|
||||
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
|
||||
"@aws-cdk/cloud-assembly-api": "^2.2.6",
|
||||
"@aws-cdk/cloud-assembly-schema": "^54.11.0",
|
||||
"@aws/cloudformation-validate": "1.5.1-beta",
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"case": "1.6.3",
|
||||
"fs-extra": "^11.3.6",
|
||||
"ignore": "^5.3.2",
|
||||
"jsonschema": "^1.5.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"minimatch": "^10.2.5",
|
||||
"punycode": "^2.3.1",
|
||||
"semver": "^7.8.5",
|
||||
"yaml": "1.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"constructs": "^10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
|
||||
"version": "2.2.6",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-cdk/cloud-assembly-schema": ">=54.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": {
|
||||
"version": "1.5.1-beta",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
|
||||
"version": "1.0.2",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/case": {
|
||||
"version": "1.6.3",
|
||||
"inBundle": true,
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
|
||||
"version": "11.3.6",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"inBundle": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"inBundle": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/yaml": {
|
||||
"version": "1.10.3",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/constructs": {
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.7.2.tgz",
|
||||
"integrity": "sha512-vA7JOqvO/xwq2HBCuq3qc6V2R9mHZCxJ8HPoucUcUWJY88YULe7bzMcsdBy27/gJJIphZi73U1oIXDY2Eqg6nA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"name": "shoc-backend-cdk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CDK ownership boundary for the shoc-backend dev deployment IAM role.",
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"synth": "npm run build && cdk synth",
|
||||
"diff": "npm run build && cdk diff",
|
||||
"deploy": "npm run build && cdk deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "2.262.1",
|
||||
"constructs": "10.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.2",
|
||||
"aws-cdk": "2.1134.0",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "Node16",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["*.ts"],
|
||||
"exclude": ["node_modules", "dist", "cdk.out"]
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# package-elastic-beanstalk.sh — build a deterministic Elastic Beanstalk source
|
||||
# bundle for the shoc-backend .NET 8 application.
|
||||
#
|
||||
# Layout of the resulting ZIP (the Beanstalk application root):
|
||||
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
|
||||
# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x)
|
||||
# ./.ebextensions/* leader-only migration container command
|
||||
#
|
||||
# The bundle reads ConnectionStrings__DefaultConnection from the runtime
|
||||
# environment (Elastic Beanstalk property). No connection string or secret is
|
||||
# written into the package.
|
||||
#
|
||||
# The script only ever removes its own generated directory (.artifacts/elastic-beanstalk)
|
||||
# and validates that path before doing so.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/package-elastic-beanstalk.sh [output.zip]
|
||||
#
|
||||
# Environment:
|
||||
# DOTNET_BIN optional path to the dotnet executable
|
||||
# RUNTIME optional target RID for local validation (default: linux-x64)
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT_ZIP="${1:-.artifacts/elastic-beanstalk/site.zip}"
|
||||
STAGING_DIR=".artifacts/elastic-beanstalk/staging"
|
||||
TOOLS_DIR=".artifacts/dotnet-tools"
|
||||
|
||||
log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
|
||||
die() { printf '\033[31mERR\033[0m %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
case "$OUTPUT_ZIP" in
|
||||
.artifacts/elastic-beanstalk/*.zip) : ;;
|
||||
*) die "output must be a .zip beneath .artifacts/elastic-beanstalk" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${DOTNET_BIN:-}" ]]; then
|
||||
DOTNET="$DOTNET_BIN"
|
||||
elif command -v dotnet >/dev/null 2>&1; then
|
||||
DOTNET="$(command -v dotnet)"
|
||||
elif [[ -x "$HOME/.dotnet/dotnet" ]]; then
|
||||
DOTNET="$HOME/.dotnet/dotnet"
|
||||
else
|
||||
die "dotnet is unavailable; set DOTNET_BIN or install the .NET 8 SDK."
|
||||
fi
|
||||
|
||||
DOTNET_DIR="$(cd "$(dirname "$DOTNET")" && pwd)"
|
||||
export PATH="$DOTNET_DIR:$PATH"
|
||||
if [[ -d "$DOTNET_DIR/host/fxr" ]]; then
|
||||
export DOTNET_ROOT="$DOTNET_DIR"
|
||||
fi
|
||||
|
||||
export DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
export DOTNET_NOLOGO=1
|
||||
export TZ=UTC
|
||||
export SOURCE_DATE_EPOCH="315532800"
|
||||
|
||||
API_PROJECT="Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj"
|
||||
MIGRATIONS_PROJECT="Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj"
|
||||
EF_VERSION="8.0.8"
|
||||
RUNTIME="${RUNTIME:-linux-x64}"
|
||||
|
||||
[[ -f "$API_PROJECT" ]] || die "missing API project: $API_PROJECT"
|
||||
[[ -f "$MIGRATIONS_PROJECT" ]] || die "missing migrations project: $MIGRATIONS_PROJECT"
|
||||
command -v zip >/dev/null 2>&1 || die "zip is required to build the source bundle."
|
||||
|
||||
GENERATED_ROOT="$(dirname "$STAGING_DIR")"
|
||||
case "$GENERATED_ROOT" in
|
||||
.artifacts/elastic-beanstalk) : ;;
|
||||
*) die "refusing to remove unexpected generated dir: $GENERATED_ROOT" ;;
|
||||
esac
|
||||
|
||||
log "clean generated artifacts"
|
||||
rm -rf "$GENERATED_ROOT" "$TOOLS_DIR"
|
||||
mkdir -p "$STAGING_DIR" "$TOOLS_DIR"
|
||||
|
||||
log "publish $API_PROJECT (Release, self-contained, $RUNTIME)"
|
||||
"$DOTNET" publish "$API_PROJECT" \
|
||||
-c Release \
|
||||
--self-contained \
|
||||
--runtime "$RUNTIME" \
|
||||
-o "$STAGING_DIR" \
|
||||
/p:ContinuousIntegrationBuild=true \
|
||||
/p:UseAppHost=true
|
||||
|
||||
log "install dotnet-ef $EF_VERSION (local tool path)"
|
||||
if ! "$DOTNET" tool install dotnet-ef --version "$EF_VERSION" --tool-path "$TOOLS_DIR" 2>/dev/null; then
|
||||
"$DOTNET" tool update dotnet-ef --version "$EF_VERSION" --tool-path "$TOOLS_DIR"
|
||||
fi
|
||||
EF="$TOOLS_DIR/dotnet-ef"
|
||||
|
||||
log "build EF migrations bundle (self-contained, $RUNTIME)"
|
||||
"$EF" migrations bundle \
|
||||
--project "$MIGRATIONS_PROJECT" \
|
||||
--startup-project "$API_PROJECT" \
|
||||
--configuration Release \
|
||||
--self-contained \
|
||||
--runtime "$RUNTIME" \
|
||||
--output "$STAGING_DIR/efbundle"
|
||||
|
||||
chmod 0755 "$STAGING_DIR/efbundle"
|
||||
|
||||
log "copy .ebextensions into bundle root"
|
||||
mkdir -p "$STAGING_DIR/.ebextensions"
|
||||
cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/"
|
||||
|
||||
log "verify no committed secret placeholders survived publish"
|
||||
if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then
|
||||
die "potential secret detected in publish output; refusing to package."
|
||||
fi
|
||||
|
||||
log "assemble source bundle (contents, not the containing directory)"
|
||||
(
|
||||
cd "$STAGING_DIR"
|
||||
# ZIP stores file mtimes. Normalize them so identical source/build inputs
|
||||
# produce byte-identical source bundles.
|
||||
find . -type f -exec touch -t 198001010000 {} +
|
||||
find . -type f -print | LC_ALL=C sort \
|
||||
| zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP"
|
||||
)
|
||||
|
||||
log "package written: $OUTPUT_ZIP"
|
||||
printf ' contents: %d files\n' "$(find "$STAGING_DIR" -type f | wc -l | tr -d ' ')"
|
||||
printf ' size: %s bytes\n' "$(wc -c < "$REPO_ROOT/$OUTPUT_ZIP" | tr -d ' ')"
|
||||
printf ' efbundle: %s\n' "$(file -b "$STAGING_DIR/efbundle" 2>/dev/null || echo present)"
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# smoke-elastic-beanstalk.sh — post-deploy smoke checks for the shoc-backend
|
||||
# dev environment.
|
||||
#
|
||||
# Checks:
|
||||
# 1. swagger.json is reachable (HTTP 200)
|
||||
# 2. swagger advertises release-critical webhook, login, and vendor routes
|
||||
# 3. protected vendor routes reject unauthenticated callers rather than 404
|
||||
# 4. an unauthenticated JSON POST to the webhook returns 401 or 503 (disabled),
|
||||
# never 404 or a server error other than the intentional 503
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/smoke-elastic-beanstalk.sh [base-url]
|
||||
# bash scripts/smoke-elastic-beanstalk.sh https://api.dev.seahaven.com
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-https://api.dev.seahaven.com}"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
|
||||
SWAGGER_URL="$BASE_URL/swagger/v1/swagger.json"
|
||||
WEBHOOK_URL="$BASE_URL/api/webhooks/work-orders"
|
||||
|
||||
log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
|
||||
ok() { printf '\033[32mPASS\033[0m %s\n' "$1"; }
|
||||
die() { printf '\033[31mFAIL\033[0m %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
command -v curl >/dev/null 2>&1 || die "curl is required."
|
||||
|
||||
mkdir -p .artifacts/elastic-beanstalk
|
||||
|
||||
log "swagger reachable: $SWAGGER_URL"
|
||||
swagger_http=$(curl -sS -o .artifacts/elastic-beanstalk/swagger.json \
|
||||
-w '%{http_code}' --max-time 30 "$SWAGGER_URL" || true)
|
||||
[[ "$swagger_http" == "200" ]] \
|
||||
|| die "swagger.json returned HTTP $swagger_http (expected 200)."
|
||||
swagger_file=".artifacts/elastic-beanstalk/swagger.json"
|
||||
ok "swagger.json HTTP 200"
|
||||
|
||||
log "swagger advertises release-critical routes"
|
||||
required_paths=(
|
||||
"/api/webhooks/work-orders"
|
||||
"/api/Authentication/login"
|
||||
"/api/Vendor/facets"
|
||||
"/api/Vendor/{id}/deactivation-impact"
|
||||
"/api/vendor-operations/notifications"
|
||||
"/api/vendor-operations/availability"
|
||||
"/api/vendor-operations/sites/{locationId}/preferred-vendors"
|
||||
"/api/vendor-operations/work-orders/{workOrderId}/assignment"
|
||||
"/api/vendor-operations/insights"
|
||||
"/api/vendor-operations/insights.csv"
|
||||
"/api/vendor-operations/insights.pdf"
|
||||
"/api/vendor-portal/dispatches/{id}/completion-documents"
|
||||
"/api/vendor-portal/dispatches/{id}/completion-documents/{documentId}"
|
||||
"/api/vendor-portal/dispatches/{id}/documents"
|
||||
"/api/vendor-portal/dispatches/{id}/documents/{documentId}"
|
||||
)
|
||||
for path in "${required_paths[@]}"; do
|
||||
grep -Fq "\"$path\"" "$swagger_file" \
|
||||
|| die "swagger.json missing $path route."
|
||||
done
|
||||
ok "release-critical webhook, login, and vendor routes present"
|
||||
|
||||
assert_protected_route() {
|
||||
local url="$1"
|
||||
local route_http
|
||||
route_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || true)
|
||||
case "$route_http" in
|
||||
401|403) ok "$url rejected an unauthenticated caller with HTTP $route_http." ;;
|
||||
404) die "$url returned 404 — route not wired (deployment broken)." ;;
|
||||
5*) die "$url returned HTTP $route_http — unexpected server error." ;;
|
||||
*) die "$url returned HTTP $route_http — expected 401 or 403." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
log "protected vendor routes are wired"
|
||||
assert_protected_route "$BASE_URL/api/Vendor/facets"
|
||||
assert_protected_route "$BASE_URL/api/vendor-operations/notifications"
|
||||
assert_protected_route "$BASE_URL/api/Vendor/1/deactivation-impact"
|
||||
|
||||
log "vendor portal rejects a missing token"
|
||||
portal_session_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \
|
||||
"$BASE_URL/api/vendor-portal/session" || true)
|
||||
[[ "$portal_session_http" == "401" ]] \
|
||||
|| die "vendor portal session returned HTTP $portal_session_http (expected 401)."
|
||||
ok "vendor portal session returned 401 without a token"
|
||||
|
||||
log "unauthenticated webhook POST: $WEBHOOK_URL"
|
||||
webhook_http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \
|
||||
-X POST -H 'Content-Type: application/json' \
|
||||
--data '{"smoke":true}' "$WEBHOOK_URL" || true)
|
||||
|
||||
case "$webhook_http" in
|
||||
401) ok "webhook returned 401 (unauthorized) as expected." ;;
|
||||
503) ok "webhook returned 503 (intentionally disabled) as expected." ;;
|
||||
404) die "webhook returned 404 — route not wired (deployment broken)." ;;
|
||||
5*) die "webhook returned HTTP $webhook_http — unexpected server error." ;;
|
||||
*) die "webhook returned HTTP $webhook_http — expected 401 or 503." ;;
|
||||
esac
|
||||
|
||||
log "smoke: all checks passed"
|
||||
Loading…
Add table
Reference in a new issue