shoc-backend/SeaHaven.Services/Implementation/WorkOrderDispatchService.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

433 lines
20 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderDispatchService : IWorkOrderDispatchService
{
private readonly IWorkOrderDataService _workOrderDataService;
private readonly IDispatchDataService _dispatchDataService;
private readonly IVendorDataService _vendorDataService;
private readonly IUserDataService _userDataService;
private readonly ITaskListTemplateDataService _taskListTemplateDataService;
private readonly ICommentDataService _commentDataService;
private readonly IDispatchEmailPort _emailPort;
private readonly IVendorTokenPort _vendorTokenPort;
private readonly FrontendOptions _frontendOptions;
public WorkOrderDispatchService(
IWorkOrderDataService workOrderDataService,
IDispatchDataService dispatchDataService,
IVendorDataService vendorDataService,
IUserDataService userDataService,
ITaskListTemplateDataService taskListTemplateDataService,
ICommentDataService commentDataService,
IDispatchEmailPort emailPort,
IVendorTokenPort vendorTokenPort,
IOptions<FrontendOptions> frontendOptions)
{
_workOrderDataService = workOrderDataService;
_dispatchDataService = dispatchDataService;
_vendorDataService = vendorDataService;
_userDataService = userDataService;
_taskListTemplateDataService = taskListTemplateDataService;
_commentDataService = commentDataService;
_emailPort = emailPort;
_vendorTokenPort = vendorTokenPort;
_frontendOptions = frontendOptions.Value;
}
public async Task<DispatchToVendorResult> DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken)
{
var workOrders = await _workOrderDataService.GetByRequestedIdsWithDetailsAsync(
input.WorkOrderIds,
cancellationToken);
if (workOrders.Count == 0)
throw new InvalidOperationException("No work orders found");
var primaryWO = workOrders[0];
var lastPO = await _dispatchDataService.GenerateNextPONumberAsync();
int nextPO = int.TryParse(lastPO, out var po) ? po : 1;
var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync();
int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1;
var currentUser = input.UserId != null ? await _userDataService.GetByIdAsync(input.UserId) : null;
var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : "";
var templateItemTexts = await LoadTemplateItemTextsAsync(input.TaskListTemplateId);
var distinctVendorIds = input.VendorIds.Distinct().ToList();
var vendors = await _vendorDataService.GetByIdsOrderedAsync(distinctVendorIds, cancellationToken);
var dispatches = new List<DispatchResultItem>();
foreach (var vendor in vendors)
{
var poNumber = $"VPO-{nextPO:D5}";
nextPO++;
var dispatchNumber = $"DSP-{nextDSP:D5}";
nextDSP++;
var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com";
var dispatch = new Dispatch
{
WorkOrderId = primaryWO.Id,
VendorId = vendor.Id,
DispatchNumber = dispatchNumber,
PONumber = poNumber,
NTEAmount = input.NTEAmount,
Description = input.Description,
Status = "Sent",
ScheduledDate = input.ScheduledDate,
DispatchedAt = DateTime.UtcNow,
ReplyToAddress = replyTo,
DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList()
};
var checklistItems = BuildChecklistItems(templateItemTexts, input.CustomChecklistItems);
dispatch.ChecklistItems = checklistItems;
var woListHtml = string.Join("", workOrders.Select(wo =>
$"<tr><td style='padding:6px 8px;border:1px solid #ddd;'>{System.Net.WebUtility.HtmlEncode(wo.InternalWONumber ?? "")}</td>" +
$"<td style='padding:6px 8px;border:1px solid #ddd;'>{System.Net.WebUtility.HtmlEncode(wo.WorkerOrderTitle ?? "")}</td>" +
$"<td style='padding:6px 8px;border:1px solid #ddd;'>{System.Net.WebUtility.HtmlEncode(wo.Priority ?? "")}</td>" +
$"<td style='padding:6px 8px;border:1px solid #ddd;'>{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}</td></tr>"));
var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? "");
var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? "");
var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}";
var descriptionHtml = System.Net.WebUtility.HtmlEncode(input.Description ?? primaryWO.Description ?? "");
var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName);
var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendor.Id);
var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
var portalUrl = $"{frontendBase}/v/{vendorToken}/dashboard";
var html = $@"
<h2>Work Order Dispatch — {dispatchNumber}</h2>
<p><strong>Location:</strong> {locationName} {locationAddr}</p>
<p><strong>Vendor PO:</strong> {poNumber} &nbsp; <strong>NTE:</strong> ${input.NTEAmount?.ToString("F2") ?? "N/A"}</p>
<h3>Work Orders</h3>
<table style='border-collapse:collapse;width:100%;font-family:Arial,sans-serif;'>
<tr style='background:#f5f5f5;'>
<th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>WO #</th>
<th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>Title</th>
<th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>Priority</th>
<th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>Due Date</th>
</tr>
{woListHtml}
</table>
<h3>Description</h3>
<p>{descriptionHtml}</p>
<br/>
<div style='text-align:center;margin:20px 0;'>
<a href='{portalUrl}' style='display:inline-block;padding:12px 32px;background:#28a745;color:white;text-decoration:none;border-radius:6px;font-weight:bold;font-size:16px;'>View Work Order</a>
</div>
<br/><p style='color:#666;font-size:13px;'>{senderNameHtml}<br/>Sea Haven Industries</p>
<hr/><p style='color:#999;font-size:12px;'>Reply to this email to communicate about this dispatch.</p>";
var emailSent = await _emailPort.SendDispatchEmailAsync(vendor.Email!, subject, html, replyTo);
dispatch.EmailSent = emailSent;
var savedDispatch = await _dispatchDataService.AddAsync(dispatch);
var auditLogs = workOrders.Select(wo => new WorkOrderAuditLog
{
WorkOrderId = wo.Id,
UserId = input.UserId,
FieldName = "Dispatch",
OldValue = "",
NewValue = $"{vendor.CompanyName} ({dispatchNumber})",
Action = AuditAction.Dispatch.ToString(),
CreatedAt = DateTime.UtcNow
}).ToList();
await _workOrderDataService.AddAuditLogsAsync(auditLogs);
dispatches.Add(new DispatchResultItem
{
Id = savedDispatch.Id,
DispatchNumber = dispatchNumber,
PONumber = poNumber,
VendorName = vendor.CompanyName,
Status = savedDispatch.Status,
WorkOrderCount = workOrders.Count
});
}
return new DispatchToVendorResult { Message = "Dispatched", Dispatches = dispatches };
}
private async Task<List<string?>?> LoadTemplateItemTextsAsync(int? taskListTemplateId)
{
if (!taskListTemplateId.HasValue)
return null;
var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(taskListTemplateId.Value);
if (template?.Items == null)
return null;
return template.Items
.OrderBy(i => i.SortOrder)
.Select(i => i.ItemText)
.ToList();
}
private static List<DispatchChecklistItem> BuildChecklistItems(List<string?>? templateItemTexts, List<string>? customChecklistItems)
{
var checklistItems = new List<DispatchChecklistItem>();
if (templateItemTexts != null)
{
checklistItems.AddRange(templateItemTexts.Select((text, idx) =>
new DispatchChecklistItem { ItemText = text, SortOrder = idx }));
}
if (customChecklistItems != null)
{
var startIdx = checklistItems.Count;
checklistItems.AddRange(customChecklistItems.Select((text, idx) =>
new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx }));
}
return checklistItems;
}
public async Task<IEnumerable<DispatchReadModel>> GetDispatchesAsync(int workOrderId)
{
var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId);
return dispatches.OrderByDescending(d => d.DispatchedAt).Select(d => new DispatchReadModel
{
Id = d.Id,
DispatchNumber = d.DispatchNumber,
PONumber = d.PONumber,
NTEAmount = d.NTEAmount,
Description = d.Description,
Status = d.Status,
ScheduledDate = d.ScheduledDate,
CompletedDate = d.CompletedDate,
DispatchedAt = d.DispatchedAt,
EmailSent = d.EmailSent,
VendorName = d.Vendor != null ? d.Vendor.CompanyName : "",
VendorEmail = d.Vendor != null ? d.Vendor.Email : "",
VendorId = d.VendorId
}).ToList();
}
public async Task<DispatchDetailReadModel?> GetDispatchDetailAsync(int id)
{
return await _dispatchDataService.GetDispatchDetailAsync(id);
}
public async Task<bool> UpdateDispatchAsync(UpdateDispatchInput input)
{
var dispatch = await _dispatchDataService.GetByIdAsync(input.Id);
if (dispatch == null)
return false;
if (dispatch.Status != input.Status)
{
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = input.UserId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = dispatch.Status ?? "",
NewValue = input.Status ?? "",
Action = "dispatch_status_change",
CreatedAt = DateTime.UtcNow
});
}
dispatch.Status = input.Status;
dispatch.NTEAmount = input.NTEAmount;
dispatch.ScheduledDate = input.ScheduledDate;
dispatch.CompletedDate = input.CompletedDate;
dispatch.Description = input.Description;
if (input.Status == "Completed" && dispatch.CompletedDate == null)
dispatch.CompletedDate = DateTime.UtcNow;
await _dispatchDataService.UpdateAsync(dispatch);
return true;
}
public async Task<AddDispatchCommentResult?> AddDispatchCommentAsync(AddDispatchCommentInput input)
{
var dispatch = await _dispatchDataService.GetByIdWithVendorAndWorkOrderAsync(input.DispatchId);
if (dispatch == null)
return null;
var user = !string.IsNullOrEmpty(input.UserId) ? await _userDataService.GetByIdAsync(input.UserId) : null;
var senderName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "";
var comment = new Comments
{
CreatedDate = DateTime.UtcNow,
UserId = input.UserId,
WorkerOrderId = dispatch.WorkOrderId,
DispatchId = dispatch.Id,
Commenttext = input.Text,
CommentType = "dispatcher",
Commenter = !string.IsNullOrWhiteSpace(senderName) ? senderName : null,
};
await _commentDataService.AddAsync(comment);
if (input.SendEmail && dispatch.Vendor?.Email != null && dispatch.WorkOrder != null)
{
var subject = $"[WO-{dispatch.WorkOrder.InternalWONumber}-{dispatch.DispatchNumber}] {dispatch.WorkOrder.WorkerOrderTitle}";
var html = $"<p>{input.Text}</p><br/><p style='color:#666;font-size:13px;'>{senderName}<br/>Sea Haven Industries</p><hr/><p style='color:#999;font-size:12px;'>Reply to this email to respond.</p>";
await _emailPort.SendDispatchEmailAsync(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress);
}
return new AddDispatchCommentResult
{
Id = comment.Id,
CreatedDate = comment.CreatedDate,
Commenttext = comment.Commenttext,
CommentType = comment.CommentType,
UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""
};
}
public async Task<VerifyDispatchResult?> VerifyDispatchAsync(int dispatchId, string userId)
{
var dispatch = await _dispatchDataService.GetByIdAsync(dispatchId);
if (dispatch == null)
return null;
var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(dispatchId);
var incompleteItems = checklistItems.Count(c => !c.IsCompleted);
var signoffs = await _dispatchDataService.GetSignoffsAsync(dispatchId);
var signoffList = signoffs.ToList();
var signoffCount = signoffList.Count;
var existingTypes = signoffList.Select(s => s.SignoffType).ToList();
var missing = new List<string>();
if (incompleteItems > 0)
missing.Add($"{incompleteItems} checklist item(s) incomplete");
if (signoffCount < 2)
{
if (!existingTypes.Contains("Customer")) missing.Add("Missing Customer sign-off");
if (!existingTypes.Contains("Vendor")) missing.Add("Missing Vendor sign-off");
}
if (missing.Count > 0)
throw new DispatchVerificationException(missing);
var user = userId != null ? await _userDataService.GetByIdAsync(userId) : null;
var userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "";
dispatch.Status = "Verified";
dispatch.VerifiedBy = userName;
dispatch.VerifiedAt = DateTime.UtcNow;
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber}",
OldValue = dispatch.Status ?? "",
NewValue = "Verified",
Action = "dispatch_verified",
CreatedAt = DateTime.UtcNow
});
await _dispatchDataService.UpdateAsync(dispatch);
return new VerifyDispatchResult
{
Message = "Dispatch verified",
VerifiedBy = userName,
VerifiedAt = dispatch.VerifiedAt
};
}
public async Task<AddDispatchSignoffResult?> AddDispatchSignoffAsync(AddDispatchSignoffInput input)
{
var dispatch = await _dispatchDataService.GetByIdAsync(input.DispatchId);
if (dispatch == null)
return null;
var existing = await _dispatchDataService.HasSignoffTypeAsync(input.DispatchId, input.SignoffType!);
if (existing)
throw new InvalidOperationException($"{input.SignoffType} sign-off already exists");
var signoff = new DispatchSignoff
{
DispatchId = input.DispatchId,
SignoffType = input.SignoffType,
Name = input.Name,
Signature = input.Signature,
SignatureMethod = input.SignatureMethod,
SignedAt = DateTime.UtcNow
};
await _dispatchDataService.AddSignoffAsync(signoff);
return new AddDispatchSignoffResult
{
Id = signoff.Id,
SignoffType = signoff.SignoffType,
Name = signoff.Name,
SignedAt = signoff.SignedAt,
SignatureMethod = signoff.SignatureMethod
};
}
public async Task<ChecklistItemUpdateResult?> UpdateChecklistItemAsync(ChecklistItemUpdateInput input)
{
var item = await _dispatchDataService.GetChecklistItemByIdAsync(input.Id);
if (item == null)
return null;
item.IsCompleted = input.IsCompleted;
item.CompletedBy = input.IsCompleted ? input.CompletedBy : null;
item.CompletedAt = input.IsCompleted ? DateTime.UtcNow : null;
await _dispatchDataService.UpdateChecklistItemAsync(item);
return new ChecklistItemUpdateResult
{
Message = "Updated",
Id = item.Id,
IsCompleted = item.IsCompleted,
CompletedBy = item.CompletedBy,
CompletedAt = item.CompletedAt
};
}
public async Task<AddChecklistItemResult> AddChecklistItemAsync(AddChecklistItemInput input)
{
var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(input.DispatchId);
var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1;
var item = new DispatchChecklistItem
{
DispatchId = input.DispatchId,
WorkOrderId = input.WorkOrderId,
ItemText = input.ItemText,
SortOrder = maxSort + 1,
IsCompleted = false
};
await _dispatchDataService.AddChecklistItemAsync(item);
return new AddChecklistItemResult
{
Id = item.Id,
ItemText = item.ItemText,
SortOrder = item.SortOrder,
IsCompleted = item.IsCompleted
};
}
}
}