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

615 lines
26 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderService : IWorkOrderService
{
private readonly IWorkOrderDataService _workOrderDataService;
private readonly ICommentDataService _commentDataService;
private readonly IUserDataService _userDataService;
private readonly IQuotesDataService _quotesDataService;
private readonly IFileStoragePort _fileStorage;
private readonly ICreateWorkOrderValidation _createValidator;
private readonly IUpdateWorkOrderValidation _updateValidator;
public WorkOrderService(
IWorkOrderDataService workOrderDataService,
ICommentDataService commentDataService,
IUserDataService userDataService,
IQuotesDataService quotesDataService,
IFileStoragePort fileStorage,
ICreateWorkOrderValidation createValidator,
IUpdateWorkOrderValidation updateValidator)
{
_workOrderDataService = workOrderDataService;
_commentDataService = commentDataService;
_userDataService = userDataService;
_quotesDataService = quotesDataService;
_fileStorage = fileStorage;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
public async Task<WorkOrderDTO?> GetWorkOrderByIdAsync(int id)
{
var workOrder = await _workOrderDataService.GetByIdAsync(id);
return workOrder == null ? null : WorkOrderDTO.MapToModel(workOrder);
}
public async Task<WorkOrderDTO?> GetWorkOrderByIdWithDetailsAsync(int id)
{
var workOrder = await _workOrderDataService.GetByIdWithDetailsAsync(id);
return workOrder == null ? null : WorkOrderDTO.MapToModel(workOrder);
}
public async Task<IEnumerable<WorkOrderDTO>> GetAllWorkOrdersAsync()
{
var workOrders = await _workOrderDataService.GetAllAsync();
return workOrders.Select(WorkOrderDTO.MapToModel);
}
public async Task<PagedResult<WorkOrderDTO>> GetWorkOrdersPagedAsync(int page, int pageSize, string? search = null, string? status = null, string? priority = null)
{
var (items, totalCount) = await _workOrderDataService.GetPagedAsync(page, pageSize, search, status, priority);
return new PagedResult<WorkOrderDTO>
{
Items = items.Select(WorkOrderDTO.MapToModel),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
public async Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId)
{
var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors);
var workOrder = CreateWorkOrderDTO.MapToEntity(dto);
workOrder.createdby = userId;
workOrder.CreatedDate = DateTime.UtcNow;
workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync();
var saved = await _workOrderDataService.AddAsync(workOrder);
await LogAuditAsync(saved.Id, userId, AuditAction.Create, "Work order created");
return saved.Id;
}
public async Task<bool> UpdateWorkOrderAsync(int id, UpdateWorkOrderDTO dto, string userId)
{
var validationResult = await _updateValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors);
var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id);
if (existingWorkOrder == null)
return false;
var changes = new List<string>();
changes.AddRange(TrackChanges(existingWorkOrder, dto));
UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder);
await _workOrderDataService.UpdateAsync(existingWorkOrder);
if (changes.Any())
await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes));
return true;
}
public async Task<bool> DeleteWorkOrderAsync(int id, string userId)
{
if (!await _workOrderDataService.ExistsAsync(id))
return false;
await _workOrderDataService.DeleteWorkOrderAttachmentsAsync(id);
await _workOrderDataService.DeleteWorkOrderContactsAsync(id);
await _workOrderDataService.DeleteWorkOrderCategoriesAsync(id);
await _workOrderDataService.DeleteAsync(id);
await LogAuditAsync(id, userId, AuditAction.Delete, "Work order deleted");
return true;
}
public async Task<bool> WorkOrderExistsAsync(int id) => await _workOrderDataService.ExistsAsync(id);
public async Task<string> GenerateNextInternalWONumberAsync() => await _workOrderDataService.GenerateNextInternalWONumberAsync();
public async Task<int> GetTotalWorkOrderCountAsync() => await _workOrderDataService.CountAsync();
public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
int page, int pageSize, string? search, string? status, string? assignee,
int? locationId, string? sortBy, string? sortDir)
{
return await _workOrderDataService.GetWorkOrderListPagedAsync(
page, pageSize, search, status, assignee, locationId, sortBy, sortDir);
}
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id)
{
return await _workOrderDataService.GetWorkOrderDetailAsync(id);
}
public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search,
string sort, string sortby, int page, int pageSize)
{
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync();
var data = allWorkOrders.AsEnumerable();
if (!string.IsNullOrEmpty(search))
{
var s = search.ToLower();
data = data.Where(w =>
(w.WorkerOrderTitle ?? "").ToLower().Contains(s) ||
(w.WorkerOrderNumber ?? "").ToLower().Contains(s) ||
(w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(s)));
}
if (assignto != null && assignto.Length > 0 && assignto[0] != "" && assignto[0] != null)
data = data.Where(w => assignto.Contains(w.AssignTo));
if (location != null && location.Length > 0 && location[0] != 0)
data = data.Where(w => location.Contains(w.LocationId ?? 0));
if (priority != null && priority.Length > 0 && priority[0] != "" && priority[0] != null)
data = data.Where(w => priority.Contains(w.Priority));
if (status != null && status.Length > 0 && status[0] != "" && status[0] != null)
data = data.Where(w => status.Contains(w.Status));
var dataList = data.ToList();
dataList = dataList.Where(w =>
duedate == null || duedate.Length == 0 || duedate[0] == null ||
duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, DateTime.UtcNow.Date))).ToList();
if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "")
{
switch (sortby)
{
case "Name":
dataList = sort.ToLower() == "ascending"
? dataList.OrderBy(w => w.WorkerOrderTitle).ToList()
: dataList.OrderByDescending(w => w.WorkerOrderTitle).ToList();
break;
case "Due Date":
dataList = sort.ToLower() == "ascending"
? dataList.OrderBy(w => w.DueDate).ToList()
: dataList.OrderByDescending(w => w.DueDate).ToList();
break;
case "Severity":
dataList = sort.ToLower() == "ascending"
? dataList.OrderBy(w => w.Priority).ToList()
: dataList.OrderByDescending(w => w.Priority).ToList();
break;
}
}
var totalCount = dataList.Count;
var pagedData = dataList
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(s => new FilteredWorkOrderReadModel
{
Id = s.Id,
WorkerOrderTitle = s.WorkerOrderTitle,
DueDate = s.DueDate,
Status = s.Status,
Priority = s.Priority,
Name = s.Locations != null ? s.Locations.Name : ""
})
.OrderByDescending(d => d.Name)
.ToList();
return new FilteredWorkOrderPageDTO
{
Data = pagedData,
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
};
}
public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async(
string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search,
string sort, string sortby, int page, int pageSize)
{
location ??= Array.Empty<int>();
priority ??= Array.Empty<string>();
status ??= Array.Empty<string>();
duedate ??= Array.Empty<string>();
search ??= string.Empty;
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync();
var totalCount = allWorkOrders.Count;
var currentDate = DateTime.UtcNow.Date;
var data = allWorkOrders.Where(w =>
(((w.WorkerOrderTitle ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) ||
(w.WorkerOrderNumber ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) ||
(w.Locations != null &&
(w.Locations.Title ?? "").Contains(search, StringComparison.OrdinalIgnoreCase))) &&
(assignto.Length == 0 || assignto.Contains(w.AssignTo)) &&
(location.Length == 0 || (w.LocationId.HasValue && location.Contains(w.LocationId.Value))) &&
(priority.Length == 0 || priority.Contains(w.Priority)) &&
(status.Length == 0 || status.Contains(w.Status)))).ToList();
data = data.Where(w =>
duedate.Length == 0 ||
duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, currentDate))).ToList();
if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "")
{
switch (sortby)
{
case "Name":
data = sort.ToLower() == "ascending"
? data.OrderBy(w => w.WorkerOrderTitle).ToList()
: data.OrderByDescending(w => w.WorkerOrderTitle).ToList();
break;
case "Due Date":
data = sort.ToLower() == "ascending"
? data.OrderBy(w => w.DueDate).ToList()
: data.OrderByDescending(w => w.DueDate).ToList();
break;
case "Severity":
data = sort.ToLower() == "ascending"
? data.OrderBy(w => w.Priority).ToList()
: data.OrderByDescending(w => w.Priority).ToList();
break;
}
}
var newdata = data
.Select(s => new FilteredWorkOrderReadModel
{
Id = s.Id,
WorkerOrderTitle = s.WorkerOrderTitle,
DueDate = s.DueDate,
Status = s.Status,
Priority = s.Priority,
Name = s.Locations != null ? s.Locations.Name : ""
})
.OrderByDescending(d => d.Name)
.ToList()
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return new FilteredWorkOrderPageDTO
{
Data = newdata,
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
};
}
public async Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, string userId)
{
var workOrder = await _workOrderDataService.GetByIdAsync(id);
if (workOrder == null) return null;
if (workOrder.Status != status)
{
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = id,
UserId = userId,
FieldName = "Status",
OldValue = workOrder.Status ?? "",
NewValue = status,
Action = AuditAction.StatusChange.ToString(),
CreatedAt = DateTime.UtcNow
});
}
workOrder.Status = status;
await _workOrderDataService.UpdateAsync(workOrder);
return WorkOrderDTO.MapToModel(workOrder);
}
public async Task<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, string userId)
{
var workOrder = await _workOrderDataService.GetByIdAsync(id);
if (workOrder == null) return null;
if (workOrder.AssignTo != assignTo)
{
var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null;
var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null;
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = id,
UserId = userId,
FieldName = "AssignedTo",
OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "",
NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned",
Action = AuditAction.AssignmentChange.ToString(),
CreatedAt = DateTime.UtcNow
});
}
workOrder.AssignTo = assignTo;
await _workOrderDataService.UpdateAsync(workOrder);
var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null;
return new ChangeAssignmentResult
{
Message = "Updated Successfully",
AssignedTo = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : ""
};
}
public async Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId)
{
var createDto = new CreateWorkOrderDTO
{
WorkerOrderTitle = input.WorkerOrderTitle,
Description = input.Description,
Status = WorkOrderStatus.Open,
Priority = Enum.TryParse<WorkOrderPriority>(input.Priority, out var p) ? p : WorkOrderPriority.Medium,
LocationId = input.LocationId,
AssignTo = input.AssignTo,
DueDate = input.DueDate
};
var workOrderId = await CreateWorkOrderAsync(createDto, userId);
if (input.ContactIds != null && input.ContactIds.Any())
await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds);
if (input.CategoryIds != null && input.CategoryIds.Any())
await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, input.CategoryIds);
await SaveAttachmentsAndPhotos(workOrderId, input.Attachments, input.BeforPhotoAttachment,
input.AfterPhotoAttachment, input.SignOffAttachment);
return workOrderId;
}
public async Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId)
{
var updateDto = new UpdateWorkOrderDTO
{
WorkerOrderTitle = input.WorkerOrderTitle,
Description = input.Description,
Status = Enum.TryParse<WorkOrderStatus>(input.Status, out var st) ? st : null,
Priority = Enum.TryParse<WorkOrderPriority>(input.Priority, out var pr) ? pr : null,
LocationId = input.LocationId,
AssignTo = input.AssignTo,
DueDate = input.DueDate,
CompletedDate = input.CompletedDate
};
var success = await UpdateWorkOrderAsync(input.Id, updateDto, userId);
if (!success) return false;
await _workOrderDataService.DeleteWorkOrderContactsAsync(input.Id);
await _workOrderDataService.DeleteWorkOrderCategoriesAsync(input.Id);
if (input.ContactIds != null && input.ContactIds.Any())
await _workOrderDataService.AddWorkOrderContactsAsync(input.Id, input.ContactIds);
if (input.CategoryIds != null && input.CategoryIds.Any())
await _workOrderDataService.AddWorkOrderCategoriesAsync(input.Id, input.CategoryIds);
await SaveAttachmentsAndPhotos(input.Id, input.Attachments, input.BeforPhotoAttachment,
input.AfterPhotoAttachment, input.SignOffAttachment);
return true;
}
public async Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId)
{
var success = await DeleteWorkOrderAsync(id, userId);
if (!success) return false;
await _quotesDataService.UnlinkFromWorkOrderAsync(id);
var comments = await _commentDataService.GetByWorkOrderIdAsync(id);
foreach (var comment in comments)
await _commentDataService.DeleteAsync(comment.Id);
return true;
}
public async Task<Comments> AddCommentAsync(AddCommentInput input, string userId)
{
var comment = new Comments
{
UserId = userId,
WorkerOrderId = input.WorkorderId,
Commenttext = input.Text,
CommentType = input.CommentType ?? "vendor"
};
if (input.Document != null)
{
var fileUrl = await _fileStorage.SaveFileAsync(input.Document);
comment.Documents = fileUrl;
}
return await _commentDataService.AddAsync(comment);
}
public async Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId)
{
var comment = new Comments
{
UserId = userId,
WorkerOrderId = input.WorkorderId,
Commenttext = input.Text,
CommentType = input.CommentType ?? "internal"
};
var saved = await _commentDataService.AddAsync(comment);
var user = await _userDataService.GetByIdAsync(userId);
return new CommentResult
{
Id = saved.Id,
CreatedDate = saved.CreatedDate,
Commenttext = saved.Commenttext,
CommentType = saved.CommentType,
UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""
};
}
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync()
{
var comments = await _commentDataService.GetAllAsync();
return comments.Select(s => new CommentListItemReadModel
{
Id = s.Id,
Commenttext = s.Commenttext,
FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "",
Documents = s.Documents
}).ToList();
}
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid)
{
var comments = await _commentDataService.GetByWorkOrderIdAsync(woid);
return comments.Select(s => new CommentListItemReadModel
{
Id = s.Id,
Commenttext = s.Commenttext,
FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "",
Documents = s.Documents
}).ToList();
}
public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync()
{
var data = await _workOrderDataService.GetAllAsync();
return data.Where(w => w.istemplate != true).ToList();
}
public async Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync()
{
var workOrders = await _workOrderDataService.GetAllWithDetailsAsync();
return workOrders.Select(s => new WorkorderFilterVM
{
Id = s.Id,
Priority = s.Priority,
Status = s.Status,
WorkerOrderNumber = s.WorkerOrderNumber,
WorkerOrderTitle = s.WorkerOrderTitle,
DueDate = s.DueDate,
AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "",
LocationName = s.Locations != null ? s.Locations.Name : "",
LocationId = s.LocationId,
PO = s.PO,
TT = s.TT
}).ToList();
}
private async Task SaveAttachmentsAndPhotos(int workOrderId,
List<Microsoft.AspNetCore.Http.IFormFile>? attachments,
Microsoft.AspNetCore.Http.IFormFile? beforPhoto,
Microsoft.AspNetCore.Http.IFormFile? afterPhoto,
Microsoft.AspNetCore.Http.IFormFile? signOffPhoto)
{
if (attachments != null)
{
foreach (var attachment in attachments)
{
var fileUrl = await _fileStorage.SaveFileAsync(attachment);
await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl
});
}
}
if (beforPhoto != null)
{
var fileUrl = await _fileStorage.SaveFileAsync(beforPhoto);
var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId);
if (workOrder != null)
{
workOrder.BeforPhotoAttachment = fileUrl;
await _workOrderDataService.UpdateAsync(workOrder);
}
}
if (afterPhoto != null)
{
var fileUrl = await _fileStorage.SaveFileAsync(afterPhoto);
var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId);
if (workOrder != null)
{
workOrder.AfterPhotoAttachment = fileUrl;
await _workOrderDataService.UpdateAsync(workOrder);
}
}
if (signOffPhoto != null)
{
var fileUrl = await _fileStorage.SaveFileAsync(signOffPhoto);
var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId);
if (workOrder != null)
{
workOrder.SignOffAttachment = fileUrl;
await _workOrderDataService.UpdateAsync(workOrder);
}
}
}
private static bool CheckDueDate(string dd, DateTime dueDate, DateTime currentDate)
{
return dd switch
{
"Today" => dueDate == currentDate,
"Tomorrow" => dueDate == currentDate.AddDays(1),
"Next Week" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddDays(7),
"Next Month" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddMonths(1),
"Over Due" => dueDate < currentDate,
_ => false
};
}
private List<string> TrackChanges(WorkOrder existing, UpdateWorkOrderDTO dto)
{
var changes = new List<string>();
if (dto.WorkerOrderTitle != null && dto.WorkerOrderTitle != existing.WorkerOrderTitle)
changes.Add($"Title: {existing.WorkerOrderTitle} → {dto.WorkerOrderTitle}");
if (dto.Status.HasValue && dto.Status.ToString() != existing.Status)
changes.Add($"Status: {existing.Status} → {dto.Status}");
if (dto.Priority.HasValue && dto.Priority.ToString() != existing.Priority)
changes.Add($"Priority: {existing.Priority} → {dto.Priority}");
if (dto.AssignTo != null && dto.AssignTo != existing.AssignTo)
changes.Add($"Assigned To: {existing.AssignTo} → {dto.AssignTo}");
if (dto.DueDate.HasValue && dto.DueDate != existing.DueDate)
changes.Add($"Due Date: {existing.DueDate:yyyy-MM-dd} → {dto.DueDate:yyyy-MM-dd}");
return changes;
}
private async Task LogAuditAsync(int workOrderId, string userId, AuditAction action, string description)
{
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
Action = action.ToString(),
FieldName = description,
UserId = userId,
CreatedAt = DateTime.UtcNow
});
}
}
}