mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 15:09:07 +00:00
feat(work-orders): add work order ingest API with X-Ingest-Key
This commit is contained in:
parent
e67254ed7e
commit
1074ed860b
7 changed files with 443 additions and 0 deletions
|
|
@ -0,0 +1,50 @@
|
||||||
|
using Api.SeaHavenIndustries.Filters;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/workorders")]
|
||||||
|
public class WorkOrderIngestController : ControllerBase
|
||||||
|
{
|
||||||
|
private const int MaxBatchSize = 100;
|
||||||
|
private readonly IWorkOrderIngestService _ingestService;
|
||||||
|
|
||||||
|
public WorkOrderIngestController(IWorkOrderIngestService ingestService)
|
||||||
|
{
|
||||||
|
_ingestService = ingestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target).
|
||||||
|
/// Auth: X-Ingest-Key header.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("ingest")]
|
||||||
|
[IngestApiKey]
|
||||||
|
[ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<WorkOrderIngestBatchResultDto>> Ingest(
|
||||||
|
[FromBody] WorkOrderIngestBatchRequestDto? batch,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = NormalizeItems(batch);
|
||||||
|
if (items.Count == 0)
|
||||||
|
return BadRequest("At least one item with externalWorkOrderId is required.");
|
||||||
|
|
||||||
|
if (items.Count > MaxBatchSize)
|
||||||
|
return BadRequest($"Max {MaxBatchSize} items per request.");
|
||||||
|
|
||||||
|
var result = await _ingestService.UpsertBatchAsync(items, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<WorkOrderIngestPayloadDto> NormalizeItems(WorkOrderIngestBatchRequestDto? batch)
|
||||||
|
{
|
||||||
|
if (batch?.Items is { Count: > 0 })
|
||||||
|
return batch.Items;
|
||||||
|
|
||||||
|
return new List<WorkOrderIngestPayloadDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
67
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Api.SeaHavenIndustries.Options;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Filters
|
||||||
|
{
|
||||||
|
/// <summary>Validates X-Ingest-Key for POST /api/workorders/ingest.</summary>
|
||||||
|
public class IngestApiKeyFilter : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
private readonly WorkOrderIngestOptions _options;
|
||||||
|
|
||||||
|
public IngestApiKeyFilter(IOptions<WorkOrderIngestOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(new { message = "Work order ingest is disabled." })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.ApiKey)
|
||||||
|
|| _options.ApiKey.Contains("${", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(new { message = "Ingest API key is not configured." })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided)
|
||||||
|
|| !FixedTimeEquals(provided.ToString(), _options.ApiKey))
|
||||||
|
{
|
||||||
|
context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FixedTimeEquals(string provided, string expected)
|
||||||
|
{
|
||||||
|
var providedBytes = Encoding.UTF8.GetBytes(provided);
|
||||||
|
var expectedBytes = Encoding.UTF8.GetBytes(expected);
|
||||||
|
return providedBytes.Length == expectedBytes.Length
|
||||||
|
&& CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public sealed class IngestApiKeyAttribute : ServiceFilterAttribute
|
||||||
|
{
|
||||||
|
public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
13
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace Api.SeaHavenIndustries.Options
|
||||||
|
{
|
||||||
|
public class WorkOrderIngestOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "WorkOrderIngest";
|
||||||
|
|
||||||
|
/// <summary>When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned.</summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Shared secret for X-Ingest-Key header (Lambda / service accounts).</summary>
|
||||||
|
public string? ApiKey { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
54
SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs
Normal file
54
SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
namespace SeaHaven.Services.DTOs
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderIngestPayloadDto
|
||||||
|
{
|
||||||
|
public required string ExternalWorkOrderId { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? WoStatus { get; set; }
|
||||||
|
public string? Severity { get; set; }
|
||||||
|
public string? Customer { get; set; }
|
||||||
|
public string? SiteCode { get; set; }
|
||||||
|
public string? Building { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public DateTime? DateReported { get; set; }
|
||||||
|
public DateTime? ScheduledStart { get; set; }
|
||||||
|
public string? SourceEmailS3Key { get; set; }
|
||||||
|
public DateTime? CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderIngestBatchRequestDto
|
||||||
|
{
|
||||||
|
public List<WorkOrderIngestPayloadDto>? Items { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderIngestItemResultDto
|
||||||
|
{
|
||||||
|
public required string ExternalWorkOrderId { get; set; }
|
||||||
|
public int WorkOrderId { get; set; }
|
||||||
|
public bool Created { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderIngestBatchResultDto
|
||||||
|
{
|
||||||
|
public int Created { get; set; }
|
||||||
|
public int Updated { get; set; }
|
||||||
|
public int Skipped { get; set; }
|
||||||
|
public List<WorkOrderIngestItemResultDto> Results { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderOpsHealthDto
|
||||||
|
{
|
||||||
|
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||||
|
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||||
|
public string? LastWeekRolledError { get; set; }
|
||||||
|
public string? LastPastDueCacheError { get; set; }
|
||||||
|
public int SyncRejectedLast24h { get; set; }
|
||||||
|
public int FieldLockCount { get; set; }
|
||||||
|
public bool SyncEnabled { get; set; }
|
||||||
|
public bool IngestEnabled { get; set; }
|
||||||
|
public bool LegacyDeprecationEnabled { get; set; }
|
||||||
|
public string? LegacySunsetDate { get; set; }
|
||||||
|
public DateTime CheckedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
27
SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs
Normal file
27
SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>DynamoDB / ingest field mapping shared by Sync and direct ingest.</summary>
|
||||||
|
public static class WorkOrderIngestFieldMapper
|
||||||
|
{
|
||||||
|
public static string? MapStatus(string? dynamoStatus)
|
||||||
|
{
|
||||||
|
return dynamoStatus switch
|
||||||
|
{
|
||||||
|
"new" => "Open",
|
||||||
|
"assigned" => "Open",
|
||||||
|
"in_progress" => "In Progress",
|
||||||
|
"on_hold" => "On Hold",
|
||||||
|
"completed" => "Done",
|
||||||
|
"cancelled" => "Cancelled",
|
||||||
|
"unknown" => "Open",
|
||||||
|
_ => string.IsNullOrWhiteSpace(dynamoStatus) ? null : "Open"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? MapSeverityToPriority(string? severity)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(severity)) return null;
|
||||||
|
return $"Sev {severity}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
SeaHaven.Services/Implementation/WorkOrderIngestService.cs
Normal file
216
SeaHaven.Services/Implementation/WorkOrderIngestService.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderIngestService : IWorkOrderIngestService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ISyncFieldMergePolicy _mergePolicy;
|
||||||
|
private readonly IWorkOrderFieldLockService _fieldLocks;
|
||||||
|
private readonly IWorkOrderAuditService _audit;
|
||||||
|
|
||||||
|
public WorkOrderIngestService(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ISyncFieldMergePolicy mergePolicy,
|
||||||
|
IWorkOrderFieldLockService fieldLocks,
|
||||||
|
IWorkOrderAuditService audit)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_mergePolicy = mergePolicy;
|
||||||
|
_fieldLocks = fieldLocks;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
|
||||||
|
IReadOnlyList<WorkOrderIngestPayloadDto> items,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new WorkOrderIngestBatchResultDto();
|
||||||
|
if (items.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var useTransaction = _db.Database.IsRelational();
|
||||||
|
await using var transaction = useTransaction
|
||||||
|
? await _db.Database.BeginTransactionAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nextSeed = await AllocateNextWoSeedAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.ExternalWorkOrderId))
|
||||||
|
{
|
||||||
|
result.Skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken);
|
||||||
|
nextSeed = updatedSeed;
|
||||||
|
result.Results.Add(upsert);
|
||||||
|
if (upsert.Created)
|
||||||
|
result.Created++;
|
||||||
|
else
|
||||||
|
result.Updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
if (transaction != null)
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (transaction != null)
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(WorkOrderIngestItemResultDto Result, int NextSeed)> UpsertOneAsync(
|
||||||
|
WorkOrderIngestPayloadDto item,
|
||||||
|
int nextSeed,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await _db.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == item.ExternalWorkOrderId, cancellationToken);
|
||||||
|
|
||||||
|
var locationId = await ResolveLocationIdAsync(
|
||||||
|
item.SiteCode, item.Building, item.Address, cancellationToken);
|
||||||
|
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed);
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
InternalWONumber = woNumber,
|
||||||
|
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||||
|
WorkerOrderNumber = item.ExternalWorkOrderId,
|
||||||
|
WorkerOrderTitle = item.Description,
|
||||||
|
Description = item.Description,
|
||||||
|
Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus) ?? "Open",
|
||||||
|
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
|
||||||
|
Severity = item.Severity,
|
||||||
|
Customer = item.Customer,
|
||||||
|
SiteCode = item.SiteCode,
|
||||||
|
Building = item.Building,
|
||||||
|
LocationId = locationId,
|
||||||
|
DueDate = item.DueDate,
|
||||||
|
DateReported = item.DateReported,
|
||||||
|
ScheduledStart = item.ScheduledStart,
|
||||||
|
SourceEmailS3Key = item.SourceEmailS3Key,
|
||||||
|
CreatedDate = item.CreatedAt ?? DateTime.UtcNow,
|
||||||
|
istemplate = false
|
||||||
|
};
|
||||||
|
_db.workOrders.Add(wo);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (new WorkOrderIngestItemResultDto
|
||||||
|
{
|
||||||
|
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||||
|
WorkOrderId = wo.Id,
|
||||||
|
Created = true
|
||||||
|
}, updatedSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncContext = new WorkOrderSyncContext
|
||||||
|
{
|
||||||
|
WorkOrder = existing,
|
||||||
|
FieldLocks = _fieldLocks,
|
||||||
|
Audit = _audit
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.Description != null)
|
||||||
|
{
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Description", item.Description);
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "WorkerOrderTitle", item.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
|
||||||
|
if (mappedStatus != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus);
|
||||||
|
|
||||||
|
var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity);
|
||||||
|
if (priority != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority);
|
||||||
|
|
||||||
|
if (item.Severity != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Severity", item.Severity);
|
||||||
|
|
||||||
|
if (item.SiteCode != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "SiteCode", item.SiteCode);
|
||||||
|
|
||||||
|
if (item.Building != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Building", item.Building);
|
||||||
|
|
||||||
|
if (locationId.HasValue)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "LocationId", locationId.Value);
|
||||||
|
|
||||||
|
if (item.DueDate.HasValue)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "DueDate", item.DueDate.Value);
|
||||||
|
|
||||||
|
if (item.Customer != null)
|
||||||
|
existing.Customer = item.Customer;
|
||||||
|
|
||||||
|
// Sync-owned metadata (not in ShocOwnedFieldSet)
|
||||||
|
existing.DateReported = item.DateReported ?? existing.DateReported;
|
||||||
|
existing.ScheduledStart = item.ScheduledStart ?? existing.ScheduledStart;
|
||||||
|
existing.SourceEmailS3Key = item.SourceEmailS3Key ?? existing.SourceEmailS3Key;
|
||||||
|
|
||||||
|
return (new WorkOrderIngestItemResultDto
|
||||||
|
{
|
||||||
|
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||||
|
WorkOrderId = existing.Id,
|
||||||
|
Created = false
|
||||||
|
}, nextSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> AllocateNextWoSeedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var maxId = await _db.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
|
||||||
|
return Math.Max(maxId + 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Number, int NextSeed) AllocateInternalWoNumber(int seed)
|
||||||
|
{
|
||||||
|
if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _))
|
||||||
|
normalized = seed.ToString().PadLeft(11, '0');
|
||||||
|
return (normalized, seed + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int?> ResolveLocationIdAsync(
|
||||||
|
string? siteCode,
|
||||||
|
string? building,
|
||||||
|
string? address,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var matchCode = siteCode ?? building;
|
||||||
|
var existing = await _db.Locations
|
||||||
|
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
return existing.Id;
|
||||||
|
|
||||||
|
var location = new Locations
|
||||||
|
{
|
||||||
|
Name = matchCode,
|
||||||
|
Title = building,
|
||||||
|
Address1 = address,
|
||||||
|
Status = "Active"
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Locations.Add(location);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return location.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs
Normal file
16
SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderIngestService
|
||||||
|
{
|
||||||
|
Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
|
||||||
|
IReadOnlyList<WorkOrderIngestPayloadDto> items,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IWorkOrderOpsHealthService
|
||||||
|
{
|
||||||
|
Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue