Merge pull request #32 from Sea-Haven-Industries/feature/sh-133-procurement-ingest

SH-133: Complete procurement work-order ingestion
This commit is contained in:
Alexandre Brandizzi 2026-07-27 17:44:31 -03:00 • committed by GitHub
commit 4bfd8bab72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 12808 additions and 128 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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