shoc-backend/SeaHaven.Services/Implementation/DispatchService.cs

340 lines
13 KiB
C#
Raw Normal View History

2026-05-06 10:49:33 -05:00
using Data.SeaHavenIndustries;
2026-05-14 11:00:12 -05:00
using FluentValidation;
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
using SeaHaven.DataServices.Dto;
2026-05-06 10:49:33 -05:00
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
2026-05-14 11:00:12 -05:00
using SeaHaven.Services.Validation;
2026-05-06 10:49:33 -05:00
namespace SeaHaven.Services.Implementation
{
/// <summary>
/// Dispatch service implementation
/// Contains business logic, validation, and orchestration for Dispatch operations
/// </summary>
public class DispatchService : IDispatchService
{
private readonly IDispatchDataService _dispatchDataService;
2026-05-14 11:00:12 -05:00
private readonly ICreateDispatchValidation _createValidator;
private readonly IUpdateDispatchValidation _updateValidator;
2026-05-06 10:49:33 -05:00
2026-05-14 11:00:12 -05:00
public DispatchService(
IDispatchDataService dispatchDataService,
ICreateDispatchValidation createValidator,
IUpdateDispatchValidation updateValidator)
2026-05-06 10:49:33 -05:00
{
_dispatchDataService = dispatchDataService;
2026-05-14 11:00:12 -05:00
_createValidator = createValidator;
_updateValidator = updateValidator;
2026-05-06 10:49:33 -05:00
}
public async Task<DispatchDTO?> GetDispatchByIdAsync(int id)
{
var dispatch = await _dispatchDataService.GetByIdAsync(id);
return dispatch == null ? null : MapToDTO(dispatch);
}
public async Task<DispatchDTO?> GetDispatchByIdWithDetailsAsync(int id)
{
var dispatch = await _dispatchDataService.GetByIdWithDetailsAsync(id);
return dispatch == null ? null : MapToDTO(dispatch);
}
public async Task<IEnumerable<DispatchDTO>> GetAllDispatchesAsync()
{
var dispatches = await _dispatchDataService.GetAllAsync();
return dispatches.Select(MapToDTO);
}
public async Task<IEnumerable<DispatchDTO>> GetDispatchesByVendorIdAsync(int vendorId)
{
var dispatches = await _dispatchDataService.GetByVendorIdAsync(vendorId);
return dispatches.Select(MapToDTO);
}
public async Task<PagedResult<DispatchDTO>> GetDispatchesPagedAsync(int page, int pageSize, string? search = null, string? status = null)
{
var (items, totalCount) = await _dispatchDataService.GetPagedAsync(page, pageSize, search, status);
return new PagedResult<DispatchDTO>
{
Items = items.Select(MapToDTO).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
public async Task<DispatchDTO> CreateDispatchAsync(CreateDispatchDTO dto, string userId)
{
2026-05-14 11:00:12 -05:00
var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
2026-05-06 10:49:33 -05:00
var dispatchNumber = await GenerateNextDispatchNumberAsync();
var dispatch = new Dispatch
{
VendorId = dto.VendorId,
ScheduledDate = dto.ScheduledDate,
Status = dto.Status ?? "Pending",
DispatchNumber = dispatchNumber,
CreatedDate = DateTime.UtcNow,
createdby = userId
};
var created = await _dispatchDataService.AddAsync(dispatch);
return MapToDTO(created);
}
public async Task<DispatchDTO> UpdateDispatchAsync(int id, UpdateDispatchDTO dto, string userId)
{
2026-05-14 11:00:12 -05:00
var validationResult = await _updateValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
2026-05-06 10:49:33 -05:00
var dispatch = await _dispatchDataService.GetByIdAsync(id);
if (dispatch == null)
throw new InvalidOperationException($"Dispatch with ID {id} not found");
if (dto.VendorId.HasValue) dispatch.VendorId = dto.VendorId.Value;
if (dto.ScheduledDate.HasValue) dispatch.ScheduledDate = dto.ScheduledDate;
if (dto.CompletedDate.HasValue) dispatch.CompletedDate = dto.CompletedDate;
if (dto.Status != null) dispatch.Status = dto.Status;
dispatch.LastModificationTime = DateTime.UtcNow;
if (int.TryParse(userId, out int userIdInt))
dispatch.LastModifierUserId = userIdInt;
await _dispatchDataService.UpdateAsync(dispatch);
return MapToDTO(dispatch);
}
public async Task DeleteDispatchAsync(int id, string userId)
{
var dispatch = await _dispatchDataService.GetByIdAsync(id);
if (dispatch == null)
throw new InvalidOperationException($"Dispatch with ID {id} not found");
dispatch.IsDeleted = true;
dispatch.DeleterUserId = userId;
dispatch.DeletionTime = DateTime.UtcNow;
await _dispatchDataService.UpdateAsync(dispatch);
}
public async Task<bool> DispatchExistsAsync(int id)
{
return await _dispatchDataService.ExistsAsync(id);
}
public async Task<string> GenerateNextDispatchNumberAsync()
{
var count = await _dispatchDataService.CountAsync();
return $"DISP-{(count + 1):D6}";
}
public async Task<int> GetTotalDispatchCountAsync()
{
return await _dispatchDataService.CountAsync();
}
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
public async Task<DispatchListResultDTO> GetDispatchListAsync(
string? search,
string? status,
int? vendorId,
DateTime? dateFrom,
DateTime? dateTo,
string? sortBy,
string? sortDir,
int page,
int pageSize,
CancellationToken cancellationToken)
{
var query = new DispatchListQuery
{
Search = search,
Status = status,
VendorId = vendorId,
DateFrom = dateFrom,
DateTo = dateTo,
SortBy = sortBy,
SortDir = sortDir,
Page = page,
PageSize = pageSize
};
var result = await _dispatchDataService.GetListAsync(query, cancellationToken);
return new DispatchListResultDTO
{
Items = result.Items.Select(MapListEntry).ToList(),
Total = result.Total,
Page = result.Page,
PageSize = result.PageSize
};
}
public async Task<DispatchDetailResultDTO?> GetDispatchDetailAsync(int id, CancellationToken cancellationToken)
{
var detail = await _dispatchDataService.GetDetailAsync(id, cancellationToken);
return detail == null ? null : MapDetail(detail);
}
private static DispatchListEntryDTO MapListEntry(DispatchListItemDTO d)
{
return new DispatchListEntryDTO
{
Id = d.Id,
DispatchNumber = d.DispatchNumber,
PONumber = d.PONumber,
NTEAmount = d.NTEAmount,
Status = d.Status,
DispatchedAt = d.DispatchedAt,
ScheduledDate = d.ScheduledDate,
CompletedDate = d.CompletedDate,
AcknowledgedAt = d.AcknowledgedAt,
VerifiedAt = d.VerifiedAt,
VendorId = d.VendorId,
VendorName = d.VendorName,
WorkOrderId = d.WorkOrderId,
PrimaryWOInternalNumber = d.PrimaryWOInternalNumber,
PrimaryWOTitle = d.PrimaryWOTitle,
LocationName = d.LocationName,
WorkOrderCount = d.WorkOrderCount,
PendingUpliftCount = d.PendingUpliftCount
};
}
private static DispatchDetailResultDTO MapDetail(DispatchDetailDTO d)
{
return new DispatchDetailResultDTO
{
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,
AcknowledgedAt = d.AcknowledgedAt,
VerifiedAt = d.VerifiedAt,
VerifiedBy = d.VerifiedBy,
EmailSent = d.EmailSent,
WorkOrderId = d.WorkOrderId,
Vendor = d.Vendor == null ? null : new DispatchDetailVendorResultDTO
{
Id = d.Vendor.Id,
CompanyName = d.Vendor.CompanyName,
ContactName = d.Vendor.ContactName,
Email = d.Vendor.Email,
Phone = d.Vendor.Phone,
Address = d.Vendor.Address,
City = d.Vendor.City,
State = d.Vendor.State,
Zip = d.Vendor.Zip,
TradeSpecialties = d.Vendor.TradeSpecialties
},
PrimaryLocation = d.PrimaryLocation == null ? null : new DispatchDetailLocationResultDTO
{
Id = d.PrimaryLocation.Id,
Name = d.PrimaryLocation.Name,
Address1 = d.PrimaryLocation.Address1,
City = d.PrimaryLocation.City,
State = d.PrimaryLocation.State,
Zip = d.PrimaryLocation.Zip
},
WorkOrders = d.WorkOrders.Select(w => new DispatchDetailLinkedWorkOrderDTO
{
WorkOrderId = w.WorkOrderId,
InternalWONumber = w.InternalWONumber,
Title = w.Title,
Priority = w.Priority,
Status = w.Status,
LocationName = w.LocationName,
Trade = w.Trade,
SubTrade = w.SubTrade,
Problem = w.Problem
}).ToList(),
Checklist = d.Checklist.Select(c => new DispatchDetailChecklistDTO
{
Id = c.Id,
ItemText = c.ItemText,
IsCompleted = c.IsCompleted,
CompletedBy = c.CompletedBy,
CompletedAt = c.CompletedAt,
SortOrder = c.SortOrder,
WorkOrderId = c.WorkOrderId
}).ToList(),
Signoffs = d.Signoffs.Select(s => new DispatchDetailSignoffDTO
{
Id = s.Id,
SignoffType = s.SignoffType,
Name = s.Name,
Signature = s.Signature,
SignatureMethod = s.SignatureMethod,
SignedAt = s.SignedAt
}).ToList(),
Comments = d.Comments.Select(c => new DispatchDetailCommentDTO
{
Id = c.Id,
Commenttext = c.Commenttext,
Commenter = c.Commenter,
CommentType = c.CommentType,
RecordType = c.RecordType,
CreatedDate = c.CreatedDate,
UserId = c.UserId,
UserName = c.UserName
}).ToList(),
UpliftRequests = d.UpliftRequests.Select(u => new DispatchDetailUpliftDTO
{
Id = u.Id,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Delta = u.Delta,
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
RequestedAt = u.RequestedAt,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
DecidedByName = u.DecidedByName
}).ToList(),
AuditLog = d.AuditLog.Select(a => new DispatchDetailAuditLogDTO
{
Id = a.Id,
Action = a.Action,
FieldName = a.FieldName,
OldValue = a.OldValue,
NewValue = a.NewValue,
CreatedAt = a.CreatedAt,
UserId = a.UserId,
UserName = a.UserName
}).ToList()
};
}
2026-05-06 10:49:33 -05:00
private DispatchDTO MapToDTO(Dispatch dispatch)
{
return new DispatchDTO
{
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
Status = dispatch.Status,
VendorId = dispatch.VendorId,
VendorName = dispatch.Vendor?.CompanyName,
ScheduledDate = dispatch.ScheduledDate,
CompletedDate = dispatch.CompletedDate,
CreatedDate = dispatch.CreatedDate,
CreatedBy = dispatch.createdby
};
}
}
}