shoc-backend/SeaHaven.Services/Implementation/WorkOrderIngestService.cs
Alexandre Brandizzi 7d245eb717
refactor: enforce backend boundaries and optimize dispatch (#30)
* refactor(api): enforce service and data-service boundaries

* refactor(api): complete feature service boundaries

* refactor(identity): enforce service and data boundaries

* refactor(vendors): enforce service and data boundaries

* refactor(workorders): enforce service and data boundaries

* refactor(backend): enforce architecture and optimize dispatch

* style(backend): format changed architecture files

* fix(architecture): address backend review follow-ups

* fix(backend): sanitize exception disclosure in changed API endpoints

Replace raw exception-message disclosure (ex.Message) returned to API
callers with a stable sanitized public message plus correlated structured
internal logging, across the endpoints changed in this PR.

- Add SanitizedErrors helper: logs the original exception at Error with a
  generated correlation id and returns a stable public message referencing
  it so support can trace without exposing internals.
- Inject ILogger<T> into the 14 changed controllers and route every
  ex.Message/dbex.Message disclosure through the helper, preserving status
  codes, response shapes, and business data (e.g. OpenWorkOrders).
- Leave FluentValidation (vex.Errors) and existing fixed-message catches
  untouched; out-of-scope controllers (Account/Contact/Employee/Asset/
  PMSchedule) are unchanged.
- Add focused tests proving internal exception text is not returned and
  that Error logging carrying the original exception is invoked.

* fix(architecture): abstract job run state access

* style: format board update service

* test: use collection assertion idiom
2026-07-24 17:35:34 -03:00

200 lines
7.8 KiB
C#

using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderIngestService : IWorkOrderIngestService
{
private readonly IWorkOrderIngestDataService _ingestData;
private readonly ISyncFieldMergePolicy _mergePolicy;
private readonly IWorkOrderFieldLockService _fieldLocks;
private readonly IWorkOrderAuditService _audit;
public WorkOrderIngestService(
IWorkOrderIngestDataService ingestData,
ISyncFieldMergePolicy mergePolicy,
IWorkOrderFieldLockService fieldLocks,
IWorkOrderAuditService audit)
{
_ingestData = ingestData;
_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;
await _ingestData.ExecuteTransactionalAsync(async cancellationToken =>
{
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 _ingestData.SaveAsync(cancellationToken);
}, cancellationToken);
return result;
}
private async Task<(WorkOrderIngestItemResultDto Result, int NextSeed)> UpsertOneAsync(
WorkOrderIngestPayloadDto item,
int nextSeed,
CancellationToken cancellationToken)
{
var existing = await _ingestData.GetTrackedByExternalIdAsync(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
};
_ingestData.TrackWorkOrder(wo);
await _ingestData.SaveAsync(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 _ingestData.GetMaxWorkOrderIdAsync(cancellationToken);
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 _ingestData.FindLocationAsync(matchCode, cancellationToken);
if (existing != null)
return existing.Id;
var location = new Locations
{
Name = matchCode,
Title = building,
Address1 = address,
Status = "Active"
};
await _ingestData.AddAndSaveLocationAsync(location, cancellationToken);
return location.Id;
}
}
}