fix(work-orders): address procurement review findings

This commit is contained in:
Alexandre Brandizzi 2026-07-27 14:40:17 -03:00
parent 8a2e260177
commit 27bf81b7f8
14 changed files with 367 additions and 34 deletions

View file

@ -1,6 +1,11 @@
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;
@ -37,6 +42,41 @@ public sealed class WorkOrderReconciliationControllerTests
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;

View file

@ -27,7 +27,10 @@ namespace Api.SeaHavenIndustries.HostedServices
{
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)
{

View file

@ -61,7 +61,7 @@
"MaxResponseBytes": 4194304,
"PollSeconds": 10,
"ScheduleMinutes": 60,
"LeaseSeconds": 120
"LeaseSeconds": 180
},
"Sync": {
"Enabled": true

View file

@ -102,6 +102,8 @@ namespace SeaHaven.DataServices.Implementation
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;

View file

@ -1,3 +1,5 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderWebhookDataService
@ -24,6 +26,7 @@ namespace SeaHaven.DataServices.Interfaces
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; }

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

@ -17,6 +17,6 @@ namespace SeaHaven.Services.Configuration
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; } = 120;
public int LeaseSeconds { get; set; } = 180;
}
}

View file

@ -49,7 +49,8 @@ namespace SeaHaven.Services.DependencyInjection
&& 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),
&& o.LeaseSeconds is >= 30 and <= 3_600
&& HasSufficientReconciliationLease(o)),
"WorkOrderReconciliation configuration is invalid.")
.ValidateOnStart();
@ -80,5 +81,25 @@ namespace SeaHaven.Services.DependencyInjection
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

@ -5,6 +5,50 @@ 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();
@ -18,5 +62,36 @@ namespace SeaHaven.Services.Helpers
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

@ -1,6 +1,7 @@
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;
@ -151,8 +152,10 @@ namespace SeaHaven.Services.Implementation
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(
@ -189,8 +192,10 @@ namespace SeaHaven.Services.Implementation
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++;
}
@ -223,7 +228,7 @@ namespace SeaHaven.Services.Implementation
private WorkOrderWebhookMutation Map(ProcurementWorkOrder item)
{
var updatedAt = item.UpdatedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
var hash = WorkOrderExternalVersion.Compute(
var hash = WorkOrderExternalVersion.ComputeWorkOrder(new WorkOrderExternalVersion.WorkOrder(
item.WorkOrderId,
item.WoStatus,
item.Description,
@ -234,16 +239,16 @@ namespace SeaHaven.Services.Implementation
item.Severity,
item.Priority,
item.AssignedTo,
Format(item.DateReported),
Format(item.ScheduledStart),
Format(item.DueDate),
item.DateReported,
item.ScheduledStart,
item.DueDate,
item.RecordType,
Format(item.CreatedAt),
item.SourceEmailS3Key,
null,
null,
null,
null);
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),
@ -259,7 +264,8 @@ namespace SeaHaven.Services.Implementation
IsStateEvent = true,
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
Description = item.Description,
Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus),
Status = status,
LifecycleStatus = lifecycleStatus,
Severity = item.Severity,
Priority = item.Priority ?? WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
AssignedTo = item.AssignedTo,
@ -279,15 +285,15 @@ namespace SeaHaven.Services.Implementation
private WorkOrderWebhookMutation Map(ProcurementWorkOrderComment item)
{
var updatedAt = item.IngestedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
var hash = WorkOrderExternalVersion.Compute(
var hash = WorkOrderExternalVersion.ComputeComment(new WorkOrderExternalVersion.Comment(
item.WorkOrderId,
item.CommentId,
item.RecordType,
item.Commenter,
item.Text,
Format(item.CreatedAt),
Format(item.IngestedAt),
item.SourceEmailS3Key);
item.CreatedAt,
item.IngestedAt,
item.SourceEmailS3Key));
return new WorkOrderWebhookMutation
{
DeliveryId = ReceiptId("comment", item.CommentId, updatedAt, hash),
@ -332,8 +338,5 @@ namespace SeaHaven.Services.Implementation
.ToLowerInvariant();
return $"reconcile:{digest}";
}
private static string? Format(DateTimeOffset? value) =>
value?.ToUniversalTime().ToString("O");
}
}

View file

@ -4,6 +4,7 @@ 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;
@ -267,7 +268,17 @@ namespace SeaHaven.Services.Implementation
return false;
var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant();
var versionHash = WorkOrderExternalVersion.Compute(
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,
@ -278,17 +289,14 @@ namespace SeaHaven.Services.Implementation
envelope.Data.Severity,
envelope.Data.Priority,
envelope.Data.AssignedTo,
envelope.Data.DateReported?.ToUniversalTime().ToString("O"),
envelope.Data.ScheduledStart?.ToUniversalTime().ToString("O"),
envelope.Data.DueDate?.ToUniversalTime().ToString("O"),
envelope.Data.DateReported,
envelope.Data.ScheduledStart,
envelope.Data.DueDate,
envelope.Data.RecordType,
envelope.Data.CreatedAt?.ToUniversalTime().ToString("O"),
envelope.Data.SourceEmailS3Key,
envelope.Data.CommentId,
envelope.Data.Commenter,
envelope.Data.Text,
envelope.Data.IngestedAt?.ToUniversalTime().ToString("O"));
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,
@ -305,7 +313,10 @@ namespace SeaHaven.Services.Implementation
IsCancelled = envelope.EventType == "work_order.cancelled",
Title = envelope.Data.Title,
Description = envelope.Data.Description,
Status = WorkOrderIngestFieldMapper.MapStatus(statusSource),
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),

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@ -51,6 +52,8 @@ public sealed class WorkOrderReconciliationTests
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);
@ -71,6 +74,27 @@ public sealed class WorkOrderReconciliationTests
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()
{
@ -299,6 +323,7 @@ public sealed class WorkOrderReconciliationTests
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,
@ -323,7 +348,7 @@ public sealed class WorkOrderReconciliationTests
CancellationToken cancellationToken)
{
RenewCount++;
return Task.FromResult(true);
return Task.FromResult(!LoseLeaseOnRenewal);
}
public Task CompleteAsync(

View file

@ -2,6 +2,7 @@ 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;
@ -282,12 +283,43 @@ public sealed class WorkOrderWebhookDataServiceTests
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)) with
{
IsCancelled = true,
Status = "Open"
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(