mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 14:13: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
210 lines
10 KiB
C#
210 lines
10 KiB
C#
using System.Text;
|
|
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Interfaces;
|
|
using System.Security.Claims;
|
|
|
|
namespace Api.SeaHavenIndustries.Controllers
|
|
{
|
|
[Authorize]
|
|
[ApiController]
|
|
[Route("api/vendor-operations")]
|
|
public class VendorOperationsController : ControllerBase
|
|
{
|
|
private readonly IVendorOperationsService _service;
|
|
|
|
public VendorOperationsController(IVendorOperationsService service)
|
|
{
|
|
_service = service;
|
|
}
|
|
|
|
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
?? User.Identity?.Name
|
|
?? "unknown";
|
|
|
|
private IActionResult Map<T>(VendorOperationOutcome<T> outcome) => outcome.Kind switch
|
|
{
|
|
VendorOperationOutcomeKind.Ok => Ok(outcome.Value),
|
|
VendorOperationOutcomeKind.NotFound => outcome.Message is null
|
|
? NotFound()
|
|
: NotFound(new Response { Status = "Error", Message = outcome.Message }),
|
|
VendorOperationOutcomeKind.BadRequest => BadRequest(new Response { Status = "Error", Message = outcome.Message }),
|
|
VendorOperationOutcomeKind.Conflict => Conflict(new Response { Status = "Conflict", Message = outcome.Message }),
|
|
_ => StatusCode(StatusCodes.Status500InternalServerError)
|
|
};
|
|
|
|
[HttpGet("notifications")]
|
|
public async Task<IActionResult> GetNotifications(CancellationToken cancellationToken)
|
|
{
|
|
var result = await _service.GetNotificationsAsync(cancellationToken);
|
|
return Ok(new { result.GeneratedAt, result.Items });
|
|
}
|
|
|
|
[HttpGet("availability")]
|
|
public async Task<IActionResult> GetAvailability(
|
|
[FromQuery] DateTime? start,
|
|
[FromQuery] DateTime? end,
|
|
[FromQuery] int? locationId,
|
|
[FromQuery] string? trade,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var outcome = await _service.GetAvailabilityAsync(start, end, locationId, trade, cancellationToken);
|
|
return Map(outcome);
|
|
}
|
|
|
|
[HttpPut("vendors/{vendorId:int}/availability")]
|
|
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] VendorAvailabilityRequest body, CancellationToken cancellationToken)
|
|
{
|
|
var outcome = await _service.UpdateAvailabilityAsync(vendorId, body?.Status, Actor(), cancellationToken);
|
|
return Map(outcome);
|
|
}
|
|
|
|
[HttpGet("sites/{locationId:int}/preferred-vendors")]
|
|
public async Task<IActionResult> GetSitePreferences(int locationId, CancellationToken cancellationToken)
|
|
{
|
|
var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken);
|
|
return Ok(preferences);
|
|
}
|
|
|
|
[HttpPut("sites/{locationId:int}/preferred-vendors")]
|
|
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body, CancellationToken cancellationToken)
|
|
{
|
|
var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List<SitePreferenceRequest>(), Actor(), cancellationToken);
|
|
return Map(outcome);
|
|
}
|
|
|
|
[HttpPut("documents/{documentId:int}/review")]
|
|
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body, CancellationToken cancellationToken)
|
|
{
|
|
var outcome = await _service.ReviewDocumentAsync(documentId, body ?? new DocumentReviewRequest(), Actor(), cancellationToken);
|
|
return Map(outcome);
|
|
}
|
|
|
|
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
|
|
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body, CancellationToken cancellationToken)
|
|
{
|
|
var outcome = await _service.UpdateCommercialStatusAsync(dispatchId, body ?? new CommercialStatusRequest(), Actor(), cancellationToken);
|
|
return Map(outcome);
|
|
}
|
|
|
|
[HttpPut("work-orders/{workOrderId:int}/assignment")]
|
|
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body, CancellationToken cancellationToken)
|
|
{
|
|
var outcome = await _service.AssignVendorAsync(workOrderId, body ?? new VendorAssignmentRequest(), Actor(), cancellationToken);
|
|
return Map(outcome);
|
|
}
|
|
|
|
[HttpGet("insights")]
|
|
public async Task<IActionResult> GetInsights(
|
|
[FromQuery] DateTime? from,
|
|
[FromQuery] DateTime? to,
|
|
[FromQuery] int? locationId,
|
|
[FromQuery] string? trade,
|
|
[FromQuery] int? vendorId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
|
|
return Ok(new
|
|
{
|
|
GeneratedAt = _service.UtcNow,
|
|
Filters = new { from, to, locationId, trade, vendorId },
|
|
MetricDefinitions = MetricDefinitions(),
|
|
Data = rows
|
|
});
|
|
}
|
|
|
|
[HttpGet("insights.csv")]
|
|
public async Task<IActionResult> ExportInsightsCsv(
|
|
[FromQuery] DateTime? from,
|
|
[FromQuery] DateTime? to,
|
|
[FromQuery] int? locationId,
|
|
[FromQuery] string? trade,
|
|
[FromQuery] int? vendorId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
|
|
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
|
|
foreach (var row in rows)
|
|
{
|
|
csv.AppendLine(string.Join(",",
|
|
row.VendorId,
|
|
Csv(row.Vendor),
|
|
row.CompletedJobs,
|
|
row.CancellationRefusalRate.ToString("0.####"),
|
|
row.OnTimeArrivalRate.ToString("0.####"),
|
|
row.OnTimeCompletionRate.ToString("0.####"),
|
|
row.AverageCycleHours?.ToString("0.##") ?? ""));
|
|
}
|
|
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.csv");
|
|
}
|
|
|
|
[HttpGet("insights.pdf")]
|
|
public async Task<IActionResult> ExportInsightsPdf(
|
|
[FromQuery] DateTime? from,
|
|
[FromQuery] DateTime? to,
|
|
[FromQuery] int? locationId,
|
|
[FromQuery] string? trade,
|
|
[FromQuery] int? vendorId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
|
|
var lines = new List<string>
|
|
{
|
|
"Vendor Performance Insights",
|
|
$"Generated: {_service.UtcNow:u}",
|
|
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
|
|
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
|
|
};
|
|
lines.AddRange(rows.Take(35).Select(row =>
|
|
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
|
|
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.pdf");
|
|
}
|
|
|
|
private static string Csv(string value)
|
|
{
|
|
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
|
|
? $"'{value}"
|
|
: value;
|
|
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
|
|
}
|
|
|
|
private static object MetricDefinitions() => new
|
|
{
|
|
CompletedJobs = "Dispatches with Completed or Verified status.",
|
|
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
|
|
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
|
|
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
|
|
AverageCycleHours = "Average elapsed hours from dispatch to completion."
|
|
};
|
|
|
|
private static byte[] SimplePdf(IEnumerable<string> lines)
|
|
{
|
|
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
|
|
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
|
|
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
|
|
+ " ET";
|
|
var objects = new[]
|
|
{
|
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
|
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
|
};
|
|
var builder = new StringBuilder("%PDF-1.4\n");
|
|
var offsets = new List<int> { 0 };
|
|
for (var index = 0; index < objects.Length; index++)
|
|
{
|
|
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
|
|
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
|
|
}
|
|
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
|
|
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
|
|
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
|
|
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
|
|
return Encoding.ASCII.GetBytes(builder.ToString());
|
|
}
|
|
}
|
|
}
|