mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 20:03:11 +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
522 lines
21 KiB
C#
522 lines
21 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.DataServices.Models;
|
|
|
|
namespace SeaHaven.DataServices.Implementation
|
|
{
|
|
public class WorkOrderDataService : IWorkOrderDataService
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public WorkOrderDataService(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public async Task<WorkOrder?> GetByIdAsync(int id)
|
|
{
|
|
return await _context.workOrders.FindAsync(id);
|
|
}
|
|
|
|
public async Task<WorkOrder?> GetByIdWithDetailsAsync(int id)
|
|
{
|
|
return await _context.workOrders
|
|
.Include(w => w.Locations)
|
|
.Include(w => w.AssignToUser)
|
|
.FirstOrDefaultAsync(w => w.Id == id);
|
|
}
|
|
|
|
public async Task<WorkOrder?> GetByInternalNumberAsync(string internalNumber)
|
|
{
|
|
return await _context.workOrders
|
|
.FirstOrDefaultAsync(w => w.InternalWONumber == internalNumber);
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrder>> GetAllAsync()
|
|
{
|
|
return await _context.workOrders.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrder>> GetByLocationIdAsync(int locationId)
|
|
{
|
|
return await _context.workOrders
|
|
.Where(w => w.LocationId == locationId)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrder>> GetByAssigneeAsync(string assignee)
|
|
{
|
|
return await _context.workOrders
|
|
.Where(w => w.AssignTo == assignee)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrder>> GetByStatusAsync(string status)
|
|
{
|
|
return await _context.workOrders
|
|
.Where(w => w.Status == status)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrder>> GetByPriorityAsync(string priority)
|
|
{
|
|
return await _context.workOrders
|
|
.Where(w => w.Priority == priority)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<(IEnumerable<WorkOrder> Items, int TotalCount)> GetPagedAsync(
|
|
int page,
|
|
int pageSize,
|
|
string? search = null,
|
|
string? status = null,
|
|
string? priority = null)
|
|
{
|
|
var query = _context.workOrders.AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
query = query.Where(w =>
|
|
w.WorkerOrderNumber!.Contains(search) ||
|
|
w.WorkerOrderTitle!.Contains(search));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
{
|
|
query = query.Where(w => w.Status == status);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(priority))
|
|
{
|
|
query = query.Where(w => w.Priority == priority);
|
|
}
|
|
|
|
var totalCount = await query.CountAsync();
|
|
var items = await query
|
|
.OrderByDescending(w => w.CreatedDate)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
return (items, totalCount);
|
|
}
|
|
|
|
public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
|
|
int page,
|
|
int pageSize,
|
|
string? search = null,
|
|
string? status = null,
|
|
string? assignee = null,
|
|
int? locationId = null,
|
|
string? sortBy = "lastUpdated",
|
|
string? sortDir = "desc")
|
|
{
|
|
search ??= "";
|
|
|
|
var query = _context.workOrders
|
|
.Include(w => w.Locations)
|
|
.Include(w => w.AssignToUser)
|
|
.Where(w => w.istemplate != true);
|
|
|
|
if (locationId.HasValue)
|
|
query = query.Where(w => w.LocationId == locationId.Value);
|
|
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
query = query.Where(w => w.Status == status);
|
|
|
|
if (assignee == "__unassigned")
|
|
query = query.Where(w => w.AssignTo == null || w.AssignTo == "");
|
|
else if (!string.IsNullOrWhiteSpace(assignee))
|
|
query = query.Where(w => w.AssignToUser != null && (w.AssignToUser.FirstName + " " + w.AssignToUser.LastName).Contains(assignee));
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
query = query.Where(w =>
|
|
(w.InternalWONumber != null && w.InternalWONumber.Contains(search)) ||
|
|
(w.WorkerOrderNumber != null && w.WorkerOrderNumber.Contains(search)) ||
|
|
(w.WorkerOrderTitle != null && w.WorkerOrderTitle.Contains(search)) ||
|
|
(w.Locations != null && w.Locations.Title != null && w.Locations.Title.Contains(search)) ||
|
|
(w.Locations != null && w.Locations.Name != null && w.Locations.Name.Contains(search))
|
|
);
|
|
}
|
|
|
|
var totalCount = await query.CountAsync();
|
|
|
|
var isAsc = sortDir?.ToLower() == "asc";
|
|
IOrderedQueryable<WorkOrder> ordered = (sortBy?.ToLower()) switch
|
|
{
|
|
"number" => isAsc ? query.OrderBy(w => w.InternalWONumber) : query.OrderByDescending(w => w.InternalWONumber),
|
|
"title" => isAsc ? query.OrderBy(w => w.WorkerOrderTitle) : query.OrderByDescending(w => w.WorkerOrderTitle),
|
|
"location" => isAsc ? query.OrderBy(w => w.Locations != null ? w.Locations.Name : "") : query.OrderByDescending(w => w.Locations != null ? w.Locations.Name : ""),
|
|
"priority" => isAsc ? query.OrderBy(w => w.Priority) : query.OrderByDescending(w => w.Priority),
|
|
"status" => isAsc ? query.OrderBy(w => w.Status) : query.OrderByDescending(w => w.Status),
|
|
"createddate" => isAsc ? query.OrderBy(w => w.CreatedDate) : query.OrderByDescending(w => w.CreatedDate),
|
|
"assignedto" => isAsc ? query.OrderBy(w => w.AssignToUser != null ? w.AssignToUser.FirstName : "") : query.OrderByDescending(w => w.AssignToUser != null ? w.AssignToUser.FirstName : ""),
|
|
"lastupdated" => isAsc ? query.OrderBy(w => w.LastModificationTime) : query.OrderByDescending(w => w.LastModificationTime),
|
|
_ => query.OrderByDescending(w => w.Id)
|
|
};
|
|
|
|
var data = await ordered
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(w => new WorkOrderListItemReadModel
|
|
{
|
|
Id = w.Id,
|
|
InternalWONumber = w.InternalWONumber,
|
|
WorkOrderNumber = w.WorkerOrderNumber,
|
|
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
|
Title = w.WorkerOrderTitle,
|
|
Location = w.Locations != null ? w.Locations.Name : "",
|
|
LocationName = w.Locations != null ? w.Locations.Name : "",
|
|
Priority = w.Priority,
|
|
Status = w.Status,
|
|
CreatedDate = w.CreatedDate,
|
|
DueDate = w.DueDate,
|
|
AssignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "",
|
|
EmployeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "",
|
|
LastUpdated = new[] {
|
|
w.LastModificationTime,
|
|
w.Comments!.Max(c => (DateTime?)c.CreatedDate),
|
|
_context.WorkOrderAuditLogs.Where(a => a.WorkOrderId == w.Id).Max(a => (DateTime?)a.CreatedAt)
|
|
}.Where(d => d.HasValue).Max()
|
|
})
|
|
.ToListAsync();
|
|
|
|
return (data, totalCount);
|
|
}
|
|
|
|
public async Task<WorkOrder> AddAsync(WorkOrder workOrder)
|
|
{
|
|
workOrder.CreatedDate = DateTime.Now;
|
|
await _context.workOrders.AddAsync(workOrder);
|
|
await _context.SaveChangesAsync();
|
|
return workOrder;
|
|
}
|
|
|
|
public async Task UpdateAsync(WorkOrder workOrder)
|
|
{
|
|
workOrder.LastModificationTime = DateTime.Now;
|
|
_context.workOrders.Update(workOrder);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task DeleteAsync(int id)
|
|
{
|
|
var entity = await GetByIdAsync(id);
|
|
if (entity != null)
|
|
{
|
|
_context.workOrders.Remove(entity);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId)
|
|
{
|
|
return await _context.WorkOrderContacts
|
|
.Where(wc => wc.WorkorderId == workOrderId)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrderCategories>> GetWorkOrderCategoriesAsync(int workOrderId)
|
|
{
|
|
return await _context.workOrderCategories
|
|
.Where(wc => wc.WorkorderId == workOrderId)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrderAttachments>> GetWorkOrderAttachmentsAsync(int workOrderId)
|
|
{
|
|
return await _context.workOrderAttachments
|
|
.Where(wa => wa.WorkorderId == workOrderId)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<Comments>> GetWorkOrderCommentsAsync(int workOrderId)
|
|
{
|
|
return await _context.Comments
|
|
.Where(c => c.WorkerOrderId == workOrderId)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrderAuditLog>> GetWorkOrderAuditLogsAsync(int workOrderId)
|
|
{
|
|
return await _context.WorkOrderAuditLogs
|
|
.Where(a => a.WorkOrderId == workOrderId)
|
|
.OrderByDescending(a => a.CreatedAt)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task AddWorkOrderContactsAsync(int workOrderId, IEnumerable<int> contactIds)
|
|
{
|
|
var contacts = contactIds.Select(cid => new WorkOrderContacts
|
|
{
|
|
WorkorderId = workOrderId,
|
|
ContactId = cid
|
|
});
|
|
|
|
await _context.WorkOrderContacts.AddRangeAsync(contacts);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable<int> categoryIds)
|
|
{
|
|
var categories = categoryIds.Select(catId => new WorkOrderCategories
|
|
{
|
|
WorkorderId = workOrderId,
|
|
CategoryId = catId
|
|
});
|
|
|
|
await _context.workOrderCategories.AddRangeAsync(categories);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment)
|
|
{
|
|
await _context.workOrderAttachments.AddAsync(attachment);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task AddAuditLogAsync(WorkOrderAuditLog auditLog)
|
|
{
|
|
await _context.WorkOrderAuditLogs.AddAsync(auditLog);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task AddAuditLogsAsync(IReadOnlyCollection<WorkOrderAuditLog> auditLogs)
|
|
{
|
|
if (auditLogs.Count == 0)
|
|
return;
|
|
|
|
await _context.WorkOrderAuditLogs.AddRangeAsync(auditLogs);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task DeleteWorkOrderContactsAsync(int workOrderId)
|
|
{
|
|
var contacts = await _context.WorkOrderContacts
|
|
.Where(wc => wc.WorkorderId == workOrderId)
|
|
.ToListAsync();
|
|
|
|
if (contacts.Any())
|
|
{
|
|
_context.WorkOrderContacts.RemoveRange(contacts);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task DeleteWorkOrderCategoriesAsync(int workOrderId)
|
|
{
|
|
var categories = await _context.workOrderCategories
|
|
.Where(wc => wc.WorkorderId == workOrderId)
|
|
.ToListAsync();
|
|
|
|
if (categories.Any())
|
|
{
|
|
_context.workOrderCategories.RemoveRange(categories);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task DeleteWorkOrderAttachmentsAsync(int workOrderId)
|
|
{
|
|
var attachments = await _context.workOrderAttachments
|
|
.Where(wa => wa.WorkorderId == workOrderId)
|
|
.ToListAsync();
|
|
|
|
if (attachments.Any())
|
|
{
|
|
_context.workOrderAttachments.RemoveRange(attachments);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task<bool> ExistsAsync(int id)
|
|
{
|
|
return await _context.workOrders.AnyAsync(w => w.Id == id);
|
|
}
|
|
|
|
public async Task<bool> InternalNumberExistsAsync(string internalNumber, int? excludeId = null)
|
|
{
|
|
return await _context.workOrders.AnyAsync(w =>
|
|
w.InternalWONumber == internalNumber &&
|
|
(excludeId == null || w.Id != excludeId));
|
|
}
|
|
|
|
public async Task<string> GenerateNextInternalWONumberAsync()
|
|
{
|
|
return await Task.FromResult($"WO-{DateTime.Now:yyyyMMdd}-{new Random().Next(1000, 9999)}");
|
|
}
|
|
|
|
public async Task<int> CountAsync()
|
|
{
|
|
return await _context.workOrders.CountAsync();
|
|
}
|
|
|
|
public async Task<int> CountByStatusAsync(string status)
|
|
{
|
|
return await _context.workOrders.CountAsync(w => w.Status == status);
|
|
}
|
|
|
|
public async Task<int> CountByPriorityAsync(string priority)
|
|
{
|
|
return await _context.workOrders.CountAsync(w => w.Priority == priority);
|
|
}
|
|
|
|
public async Task<int> CountByLocationAsync(int locationId)
|
|
{
|
|
return await _context.workOrders.CountAsync(w => w.LocationId == locationId);
|
|
}
|
|
|
|
public async Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync()
|
|
{
|
|
return await _context.workOrders
|
|
.Where(w => w.istemplate != true)
|
|
.Include(w => w.Locations)
|
|
.Include(w => w.AssignToUser)
|
|
.AsSplitQuery()
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WorkOrder>> GetByRequestedIdsWithDetailsAsync(
|
|
IReadOnlyCollection<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var orderedIds = ids.Distinct().ToList();
|
|
if (orderedIds.Count == 0)
|
|
return Array.Empty<WorkOrder>();
|
|
|
|
var loaded = await _context.workOrders
|
|
.AsNoTracking()
|
|
.Where(w => orderedIds.Contains(w.Id) && w.istemplate != true)
|
|
.Include(w => w.Locations)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var byId = loaded.ToDictionary(w => w.Id);
|
|
var ordered = new List<WorkOrder>(orderedIds.Count);
|
|
foreach (var id in orderedIds)
|
|
{
|
|
if (byId.TryGetValue(id, out var workOrder))
|
|
ordered.Add(workOrder);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id)
|
|
{
|
|
return await _context.workOrders
|
|
.AsNoTracking()
|
|
.Where(w => w.Id == id)
|
|
.Select(s => new WorkOrderDetailReadModel
|
|
{
|
|
Id = s.Id,
|
|
InternalWONumber = s.InternalWONumber,
|
|
WorkerOrderNumber = s.WorkerOrderNumber,
|
|
Title = s.WorkerOrderTitle,
|
|
Description = s.Description,
|
|
CreatedDate = s.CreatedDate,
|
|
DueDate = s.DueDate,
|
|
ScheduledDate = s.ScheduledDate,
|
|
CompletedDate = s.CompletedDate,
|
|
Status = s.Status,
|
|
Assignee = s.AssignToUser != null ? s.AssignToUser.FirstName : "",
|
|
Location = s.Locations != null ? s.Locations.Name : "",
|
|
LocationId = s.LocationId,
|
|
LocationZip = s.Locations != null ? (s.Locations.Zip ?? ExtractZip(s.Locations.Address1)) : "",
|
|
Priority = s.Priority,
|
|
Problem = s.Problem,
|
|
Trade = s.Trade,
|
|
SubTrade = s.SubTrade,
|
|
VendorNTE = s.VendorNTE,
|
|
Source = s.Source,
|
|
PO = s.PO,
|
|
TT = s.TT,
|
|
Poc = s.WorkOrderContacts!.Select(c => new ContactReadModel
|
|
{
|
|
Id = c.Id,
|
|
Name = c.POC != null ? (c.POC.FirstName + " " + c.POC.LastName).Trim() : "",
|
|
Email = c.POC != null ? c.POC.Email : "",
|
|
Phone = c.POC != null ? c.POC.PhoneNumber : ""
|
|
}).ToList(),
|
|
Category = s.WorkOrderCategories!.Select(c => new CategoryReadModel
|
|
{
|
|
Id = c.Id,
|
|
Name = c.Category != null ? c.Category.Name : ""
|
|
}).ToList(),
|
|
Attachments = s.workOrderAttachments!.Select(a => new AttachmentReadModel
|
|
{
|
|
Id = a.Id,
|
|
Attachment = a.Attachments
|
|
}).ToList(),
|
|
BeforPhotoIssue = s.BeforPhotoIssue,
|
|
BeforPhotoAttachment = s.BeforPhotoAttachment,
|
|
AfterPhotoIssue = s.AfterPhotoIssue,
|
|
AfterPhotoAttachment = s.AfterPhotoAttachment,
|
|
SignOffName = s.SignOffName,
|
|
SignOffAttachment = s.SignOffAttachment,
|
|
SignOffSignature = s.SignOffSignature,
|
|
Comments = s.Comments!.Select(c => new CommentReadModel
|
|
{
|
|
Id = c.Id,
|
|
CreatedDate = c.CreatedDate,
|
|
Commenttext = c.Commenttext,
|
|
Commenter = c.Commenter,
|
|
RecordType = c.RecordType,
|
|
CommentType = c.CommentType,
|
|
Documents = c.Documents,
|
|
UserName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : ""
|
|
}).ToList(),
|
|
Quotes = s.Quotes!.Select(q => new QuoteRefReadModel
|
|
{
|
|
Code = q.QuotId,
|
|
Id = q.Id
|
|
}).ToList(),
|
|
AuditLog = _context.WorkOrderAuditLogs
|
|
.Where(a => a.WorkOrderId == s.Id)
|
|
.OrderByDescending(a => a.CreatedAt)
|
|
.Select(a => new AuditLogReadModel
|
|
{
|
|
Id = a.Id,
|
|
FieldName = a.FieldName,
|
|
OldValue = a.OldValue,
|
|
NewValue = a.NewValue,
|
|
Action = a.Action,
|
|
CreatedAt = a.CreatedAt,
|
|
UserName = a.User != null ? a.User.FirstName + " " + a.User.LastName : ""
|
|
}).ToList(),
|
|
Dispatches = s.Dispatches!.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()
|
|
}).FirstOrDefaultAsync();
|
|
}
|
|
|
|
public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync()
|
|
{
|
|
return await _context.workOrders
|
|
.Where(w => w.istemplate != true)
|
|
.Include(w => w.Locations)
|
|
.ToListAsync();
|
|
}
|
|
|
|
private static string? ExtractZip(string? address)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(address)) return null;
|
|
var match = System.Text.RegularExpressions.Regex.Match(address, @"\b(\d{5})(?:-\d{4})?\b");
|
|
return match.Success ? match.Groups[1].Value : null;
|
|
}
|
|
}
|
|
}
|