mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 16:33: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
477 lines
19 KiB
C#
477 lines
19 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
|
|
namespace SeaHaven.DataServices.Implementation
|
|
{
|
|
public class VendorDataService : IVendorDataService
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public VendorDataService(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public async Task<Vendor?> GetByIdAsync(int id)
|
|
{
|
|
return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id);
|
|
}
|
|
|
|
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
|
|
{
|
|
return await ProjectWithTotalJobs(
|
|
_context.Vendors
|
|
.AsNoTracking()
|
|
.Where(v => v.Id == id))
|
|
.FirstOrDefaultAsync();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken)
|
|
{
|
|
var orderedIds = ids.Distinct().ToList();
|
|
if (orderedIds.Count == 0)
|
|
return Array.Empty<Vendor>();
|
|
|
|
var loaded = await _context.Vendors
|
|
.AsNoTracking()
|
|
.Where(v => orderedIds.Contains(v.Id))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var byId = loaded.ToDictionary(v => v.Id);
|
|
var ordered = new List<Vendor>(orderedIds.Count);
|
|
foreach (var id in orderedIds)
|
|
{
|
|
if (byId.TryGetValue(id, out var vendor))
|
|
ordered.Add(vendor);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
public async Task<IEnumerable<Vendor>> GetAllAsync()
|
|
{
|
|
return await _context.Vendors.ToListAsync();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken)
|
|
{
|
|
return await _context.Vendors
|
|
.AsNoTracking()
|
|
.Where(v => v.IsActive)
|
|
.OrderBy(v => v.CompanyName)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
|
{
|
|
var query = _context.Vendors
|
|
.AsNoTracking()
|
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
|
|
|
if (isActive.HasValue)
|
|
query = query.Where(v => v.IsActive == isActive.Value);
|
|
|
|
return await query
|
|
.OrderByDescending(v => v.Id)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
|
int page,
|
|
int pageSize,
|
|
string? search = null,
|
|
bool? isActive = true,
|
|
IReadOnlyCollection<string>? companies = null,
|
|
IReadOnlyCollection<string>? trades = null,
|
|
IReadOnlyCollection<string>? locations = null,
|
|
IReadOnlyCollection<string>? jobBuckets = null)
|
|
{
|
|
var projectedQuery = BuildDirectoryQuery(
|
|
search,
|
|
isActive,
|
|
companies,
|
|
trades,
|
|
locations,
|
|
jobBuckets);
|
|
|
|
var totalCount = await projectedQuery.CountAsync();
|
|
var items = await projectedQuery
|
|
.OrderByDescending(v => v.Id)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
return (items, totalCount);
|
|
}
|
|
|
|
internal IQueryable<Vendor> BuildDirectoryQuery(
|
|
string? search = null,
|
|
bool? isActive = true,
|
|
IReadOnlyCollection<string>? companies = null,
|
|
IReadOnlyCollection<string>? trades = null,
|
|
IReadOnlyCollection<string>? locations = null,
|
|
IReadOnlyCollection<string>? jobBuckets = null)
|
|
{
|
|
var query = _context.Vendors
|
|
.AsNoTracking()
|
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
|
|
|
if (isActive.HasValue)
|
|
query = query.Where(v => v.IsActive == isActive.Value);
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
var normalizedSearch = search.ToLower();
|
|
query = query.Where(v =>
|
|
(v.CompanyName ?? "").ToLower().Contains(normalizedSearch) ||
|
|
(v.ContactName ?? "").ToLower().Contains(normalizedSearch) ||
|
|
(v.Email ?? "").ToLower().Contains(normalizedSearch) ||
|
|
(v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) ||
|
|
(v.City ?? "").ToLower().Contains(normalizedSearch) ||
|
|
(v.State ?? "").ToLower().Contains(normalizedSearch) ||
|
|
(v.Zip ?? "").ToLower().Contains(normalizedSearch));
|
|
}
|
|
|
|
var selectedCompanies = companies?
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value.Trim().ToLower())
|
|
.Distinct()
|
|
.ToArray() ?? Array.Empty<string>();
|
|
if (selectedCompanies.Length > 0)
|
|
query = query.Where(v =>
|
|
selectedCompanies.Contains((v.CompanyName ?? "").ToLower()));
|
|
|
|
var selectedTrades = trades?
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value.Trim().ToLower())
|
|
.Distinct()
|
|
.ToArray() ?? Array.Empty<string>();
|
|
if (selectedTrades.Length > 0)
|
|
query = query.Where(BuildTradePredicate(selectedTrades));
|
|
|
|
var selectedLocations = locations?
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value.Trim().ToLower())
|
|
.Distinct()
|
|
.ToArray() ?? Array.Empty<string>();
|
|
if (selectedLocations.Length > 0)
|
|
query = query.Where(BuildLocationPredicate(selectedLocations));
|
|
|
|
var result = ProjectWithTotalJobs(query);
|
|
var selectedJobBuckets = jobBuckets?
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value.Trim().ToLower())
|
|
.Distinct()
|
|
.ToArray() ?? Array.Empty<string>();
|
|
if (selectedJobBuckets.Length == 0)
|
|
return result;
|
|
|
|
var under50 = selectedJobBuckets.Contains("under-50");
|
|
var from50To99 = selectedJobBuckets.Contains("50-99");
|
|
var from100To149 = selectedJobBuckets.Contains("100-149");
|
|
var from150 = selectedJobBuckets.Contains("150-plus");
|
|
|
|
return result.Where(v =>
|
|
(under50 && v.TotalJobs < 50) ||
|
|
(from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) ||
|
|
(from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) ||
|
|
(from150 && v.TotalJobs >= 150));
|
|
}
|
|
|
|
private IQueryable<Vendor> ProjectWithTotalJobs(IQueryable<Vendor> query) =>
|
|
query.Select(v => new Vendor
|
|
{
|
|
Id = v.Id,
|
|
CompanyName = v.CompanyName,
|
|
CompanyId = v.CompanyId,
|
|
ContactName = v.ContactName,
|
|
Email = v.Email,
|
|
Phone = v.Phone,
|
|
CompanyPhone = v.CompanyPhone,
|
|
PreferredContact = v.PreferredContact,
|
|
Address = v.Address,
|
|
City = v.City,
|
|
State = v.State,
|
|
Zip = v.Zip,
|
|
TradeSpecialties = v.TradeSpecialties,
|
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
|
Notes = v.Notes,
|
|
IsActive = v.IsActive,
|
|
IsDeleted = v.IsDeleted,
|
|
TotalJobs = _context.Dispatches
|
|
.Where(dispatch =>
|
|
dispatch.VendorId == v.Id &&
|
|
dispatch.WorkOrderId.HasValue)
|
|
.Select(dispatch => dispatch.WorkOrderId!.Value)
|
|
.Union(
|
|
_context.DispatchWorkOrders
|
|
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
|
.Select(link => link.WorkOrderId))
|
|
.Count()
|
|
});
|
|
|
|
private static readonly MethodInfo StringConcatMethod =
|
|
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
|
|
|
|
private static Expression<Func<Vendor, bool>> BuildTradePredicate(
|
|
IReadOnlyCollection<string> selectedTrades)
|
|
{
|
|
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
|
var specialties = Expression.Coalesce(
|
|
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
|
Expression.Constant(string.Empty));
|
|
var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes);
|
|
|
|
var delimiters = new[] { ", ", "," };
|
|
Expression? body = null;
|
|
|
|
foreach (var trade in selectedTrades)
|
|
{
|
|
var token = Expression.Constant(trade);
|
|
|
|
body = OrElse(body, Expression.Equal(lowered, token));
|
|
|
|
foreach (var delimiter in delimiters)
|
|
{
|
|
var delim = Expression.Constant(delimiter);
|
|
var tokenWithTrailing = Concat(token, delim);
|
|
var leadingToken = Concat(delim, token);
|
|
var wrappedToken = Concat(leadingToken, delim);
|
|
|
|
body = OrElse(body, StartsWith(lowered, tokenWithTrailing));
|
|
body = OrElse(body, EndsWith(lowered, leadingToken));
|
|
body = OrElse(body, Contains(lowered, wrappedToken));
|
|
}
|
|
}
|
|
|
|
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
|
}
|
|
|
|
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
|
|
IReadOnlyCollection<string> selectedLocations)
|
|
{
|
|
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
|
var label = BuildLocationLabelBody(vendor);
|
|
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
|
|
|
Expression? body = null;
|
|
foreach (var location in selectedLocations)
|
|
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
|
|
|
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
|
}
|
|
|
|
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
|
{
|
|
var city = Expression.Property(vendor, nameof(Vendor.City));
|
|
var state = Expression.Property(vendor, nameof(Vendor.State));
|
|
var cityBlank = IsBlank(city);
|
|
var stateBlank = IsBlank(state);
|
|
|
|
var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state);
|
|
|
|
var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state);
|
|
var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState);
|
|
return Expression.Condition(cityBlank, onlyState, cityOrBoth);
|
|
}
|
|
|
|
private static Expression IsBlank(Expression value)
|
|
{
|
|
var isNull = Expression.Equal(value, Expression.Constant(null, value.Type));
|
|
var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes);
|
|
var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty));
|
|
return Expression.Condition(isNull, Expression.Constant(true), isEmpty);
|
|
}
|
|
|
|
private static Expression Concat(Expression left, Expression right) =>
|
|
Expression.Call(StringConcatMethod, left, right);
|
|
|
|
private static Expression StartsWith(Expression instance, Expression value) =>
|
|
Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value);
|
|
|
|
private static Expression EndsWith(Expression instance, Expression value) =>
|
|
Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value);
|
|
|
|
private static Expression Contains(Expression instance, Expression value) =>
|
|
Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value);
|
|
|
|
private static Expression OrElse(Expression? accumulated, Expression next) =>
|
|
accumulated is null ? next : Expression.OrElse(accumulated, next);
|
|
|
|
public async Task<Vendor> AddAsync(Vendor vendor)
|
|
{
|
|
await _context.Vendors.AddAsync(vendor);
|
|
await _context.SaveChangesAsync();
|
|
return vendor;
|
|
}
|
|
|
|
public async Task UpdateAsync(Vendor vendor)
|
|
{
|
|
_context.Vendors.Update(vendor);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task DeleteAsync(int id)
|
|
{
|
|
var entity = await GetByIdAsync(id);
|
|
if (entity != null)
|
|
{
|
|
_context.Vendors.Remove(entity);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task<bool> ExistsAsync(int id)
|
|
{
|
|
return await _context.Vendors.AnyAsync(v => v.Id == id);
|
|
}
|
|
|
|
public async Task<VendorAccessToken?> GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
return await _context.VendorAccessTokens
|
|
.AsNoTracking()
|
|
.Where(t => t.VendorId == vendorId
|
|
&& t.RevokedAt == null
|
|
&& t.ExpiresAt > now
|
|
&& (t.IsDeleted == null || t.IsDeleted == false))
|
|
.OrderByDescending(t => t.IssuedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<VendorAccessToken?> GetTokenWithVendorAsync(string token, CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
return await _context.VendorAccessTokens
|
|
.Include(t => t.Vendor)
|
|
.FirstOrDefaultAsync(t => t.Token == token
|
|
&& t.RevokedAt == null
|
|
&& t.ExpiresAt > now
|
|
&& (t.IsDeleted == null || t.IsDeleted == false), cancellationToken);
|
|
}
|
|
|
|
public async Task<VendorAccessToken> AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken)
|
|
{
|
|
await _context.VendorAccessTokens.AddAsync(token, cancellationToken);
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
return token;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<VendorAccessToken>> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken)
|
|
{
|
|
return await _context.VendorAccessTokens
|
|
.Where(t => t.VendorId == vendorId && t.RevokedAt == null)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
|
{
|
|
await _context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<int> CountAsync()
|
|
{
|
|
return await _context.Vendors.CountAsync();
|
|
}
|
|
|
|
public async Task<VendorCompany?> GetCompanyByIdAsync(int id)
|
|
{
|
|
return await _context.VendorCompanies
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(c => c.Id == id);
|
|
}
|
|
|
|
public async Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName)
|
|
{
|
|
return await _context.VendorCompanies
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(c => c.NormalizedName == normalizedName);
|
|
}
|
|
|
|
public async Task<VendorCompany> AddCompanyAsync(VendorCompany company)
|
|
{
|
|
await _context.VendorCompanies.AddAsync(company);
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
return company;
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
_context.Entry(company).State = EntityState.Detached;
|
|
var existing = await _context.VendorCompanies
|
|
.AsNoTracking()
|
|
.SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName);
|
|
if (existing != null)
|
|
return existing;
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task UpdateCompanyAsync(VendorCompany company)
|
|
{
|
|
_context.VendorCompanies.Update(company);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
internal IQueryable<LinkedWorkOrderInfo> BuildLinkedWorkOrdersQuery(int vendorId)
|
|
{
|
|
var directLinks = _context.Dispatches
|
|
.Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue)
|
|
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value });
|
|
|
|
var multiLinks = _context.DispatchWorkOrders
|
|
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId)
|
|
.Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId });
|
|
|
|
var linkedIds = directLinks.Concat(multiLinks);
|
|
|
|
return from link in linkedIds
|
|
join wo in _context.workOrders on link.WorkOrderId equals wo.Id
|
|
orderby wo.Id, link.DispatchId
|
|
select new LinkedWorkOrderInfo
|
|
{
|
|
WorkOrderId = wo.Id,
|
|
WorkOrderNumber = wo.WorkerOrderNumber,
|
|
WorkOrderTitle = wo.WorkerOrderTitle,
|
|
Status = wo.Status,
|
|
LifecycleStatus = wo.LifecycleStatus,
|
|
ScheduledDate = wo.ScheduledDate,
|
|
DispatchId = link.DispatchId
|
|
};
|
|
}
|
|
|
|
public async Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId)
|
|
{
|
|
var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync();
|
|
return raw.DistinctBy(x => x.WorkOrderId).ToList();
|
|
}
|
|
|
|
public async Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId)
|
|
{
|
|
var hasDirect = await _context.Dispatches
|
|
.AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId);
|
|
|
|
if (hasDirect)
|
|
return true;
|
|
|
|
return await _context.DispatchWorkOrders
|
|
.AnyAsync(link => link.WorkOrderId == workOrderId
|
|
&& link.Dispatch != null
|
|
&& link.Dispatch.VendorId == vendorId);
|
|
}
|
|
|
|
public async Task UpdateWithAuditLogsAsync(
|
|
Vendor vendor,
|
|
IReadOnlyCollection<VendorAuditLog> auditLogs)
|
|
{
|
|
_context.Vendors.Update(vendor);
|
|
if (auditLogs.Count > 0)
|
|
await _context.VendorAuditLogs.AddRangeAsync(auditLogs);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
}
|
|
}
|