mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
* 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
176 lines
7.9 KiB
C#
176 lines
7.9 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class WorkOrderCompletionService : IWorkOrderCompletionService
|
|
{
|
|
private readonly IWorkOrderCompletionDataService _completionData;
|
|
private readonly ICompletionDocTemplateDataService _templateData;
|
|
private readonly IWorkOrderDetailDataService _detailData;
|
|
private readonly IWorkOrderAuditService _auditService;
|
|
|
|
public WorkOrderCompletionService(
|
|
IWorkOrderCompletionDataService completionData,
|
|
ICompletionDocTemplateDataService templateData,
|
|
IWorkOrderDetailDataService detailData,
|
|
IWorkOrderAuditService auditService)
|
|
{
|
|
_completionData = completionData;
|
|
_templateData = templateData;
|
|
_detailData = detailData;
|
|
_auditService = auditService;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
|
|
{
|
|
var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType);
|
|
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
|
|
}
|
|
|
|
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
|
|
{
|
|
var row = await _templateData.GetByIdAsync(id);
|
|
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
|
|
}
|
|
|
|
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
|
int workOrderId,
|
|
WorkOrderCompletionDocUploadDto request,
|
|
string fileUrl,
|
|
string? actorId)
|
|
{
|
|
var workOrder = await _completionData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
|
|
|
|
if (workOrder == null)
|
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
|
|
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
|
|
|
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
|
|
if (workOrderVersion == null)
|
|
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
|
|
|
if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion))
|
|
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
|
|
|
|
_completionData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
|
|
|
var oldDocStatus = workOrder.DocStatus?.ToString();
|
|
var oldAttachment = workOrder.SignOffAttachment;
|
|
|
|
workOrder.SignOffAttachment = fileUrl;
|
|
if (!string.IsNullOrWhiteSpace(request.SignOffName))
|
|
workOrder.SignOffName = request.SignOffName.Trim();
|
|
if (!string.IsNullOrWhiteSpace(request.SignOffSignature))
|
|
workOrder.SignOffSignature = request.SignOffSignature.Trim();
|
|
|
|
workOrder.DocStatus = DocStatus.Yes;
|
|
|
|
await _auditService.StageFieldChangedAsync(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId);
|
|
if (oldDocStatus != DocStatus.Yes.ToString())
|
|
await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
|
|
|
|
await _completionData.SaveAsync(CancellationToken.None);
|
|
|
|
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
|
|
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
|
|
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
|
|
|
|
return new WorkOrderCompletionDto
|
|
{
|
|
DocStatus = workOrder.DocStatus,
|
|
Template = template == null ? null : WorkOrderDetailService.MapTemplate(template),
|
|
SignOffName = extended?.SignOffName,
|
|
SignOffAttachment = extended?.SignOffAttachment,
|
|
SignOffSignature = extended?.SignOffSignature,
|
|
DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto
|
|
{
|
|
DispatchId = s.DispatchId,
|
|
SignoffType = s.SignoffType,
|
|
Name = s.Name,
|
|
SignedAt = s.SignedAt?.ToUniversalTime().ToString("o")
|
|
}).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request)
|
|
{
|
|
ValidateTemplateRequest(request);
|
|
|
|
var entity = new CompletionDocTemplate
|
|
{
|
|
Name = request.Name.Trim(),
|
|
ServiceKey = request.ServiceKey.Trim(),
|
|
WorkOrderType = request.WorkOrderType,
|
|
TemplateUrl = request.TemplateUrl.Trim(),
|
|
IsActive = request.IsActive
|
|
};
|
|
|
|
var created = await _templateData.CreateAsync(entity);
|
|
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
|
created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive));
|
|
}
|
|
|
|
public async Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request)
|
|
{
|
|
ValidateTemplateRequest(request);
|
|
|
|
var entity = await _completionData.GetTrackedTemplateAsync(id, CancellationToken.None);
|
|
if (entity == null)
|
|
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
|
|
|
entity.Name = request.Name.Trim();
|
|
entity.ServiceKey = request.ServiceKey.Trim();
|
|
entity.WorkOrderType = request.WorkOrderType;
|
|
entity.TemplateUrl = request.TemplateUrl.Trim();
|
|
entity.IsActive = request.IsActive;
|
|
|
|
await _templateData.UpdateAsync(entity);
|
|
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
|
entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive));
|
|
}
|
|
|
|
public async Task DeleteTemplateAsync(int id)
|
|
{
|
|
if (!await _templateData.DeleteAsync(id))
|
|
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
|
}
|
|
|
|
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))
|
|
throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required.");
|
|
|
|
if (string.IsNullOrWhiteSpace(request.TemplateUrl))
|
|
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required.");
|
|
|
|
if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri)
|
|
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
|
{
|
|
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
|
|
}
|
|
}
|
|
|
|
private static byte[]? ParseRowVersion(string? base64)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(base64))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
return Convert.FromBase64String(base64);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
|
|
}
|
|
}
|
|
}
|
|
}
|