mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 23:33:10 +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
665 lines
26 KiB
C#
665 lines
26 KiB
C#
using Data.SeaHavenIndustries;
|
||
using Data.SeaHavenIndustries.Enums;
|
||
using FluentValidation;
|
||
using FluentValidation.Results;
|
||
using Microsoft.Extensions.Options;
|
||
using SeaHaven.DataServices.Interfaces;
|
||
using SeaHaven.Services.Configuration;
|
||
using SeaHaven.Services.DTOs;
|
||
using SeaHaven.Services.Helpers;
|
||
using SeaHaven.Services.Interfaces;
|
||
using SeaHaven.Services.Validation;
|
||
|
||
namespace SeaHaven.Services.Implementation
|
||
{
|
||
public class VendorService : IVendorService
|
||
{
|
||
private static readonly string[] TerminalLegacyStatuses =
|
||
{ "completed", "cancelled", "canceled" };
|
||
|
||
private readonly IVendorDataService _vendorDataService;
|
||
private readonly IVendorPortalTokenService _vendorTokenService;
|
||
private readonly IZipCodeDistance _zipDistance;
|
||
private readonly FrontendOptions _frontendOptions;
|
||
private readonly ICreateVendorValidation _createValidator;
|
||
private readonly IUpdateVendorValidation _updateValidator;
|
||
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
|
||
|
||
public VendorService(
|
||
IVendorDataService vendorDataService,
|
||
IVendorPortalTokenService vendorTokenService,
|
||
IZipCodeDistance zipDistance,
|
||
IOptions<FrontendOptions> frontendOptions,
|
||
ICreateVendorValidation createValidator,
|
||
IUpdateVendorValidation updateValidator,
|
||
IWorkOrderVendorUpdateValidation workOrderUpdateValidator)
|
||
{
|
||
_vendorDataService = vendorDataService;
|
||
_vendorTokenService = vendorTokenService;
|
||
_zipDistance = zipDistance;
|
||
_frontendOptions = frontendOptions.Value;
|
||
_createValidator = createValidator;
|
||
_updateValidator = updateValidator;
|
||
_workOrderUpdateValidator = workOrderUpdateValidator;
|
||
}
|
||
|
||
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
|
||
{
|
||
var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id);
|
||
return vendor == null ? null : MapToDTO(vendor);
|
||
}
|
||
|
||
public async Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id)
|
||
{
|
||
var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id);
|
||
return vendor == null ? null : MapToDTO(vendor);
|
||
}
|
||
|
||
public async Task<IEnumerable<VendorDTO>> GetAllVendorsAsync()
|
||
{
|
||
var vendors = await _vendorDataService.GetAllAsync();
|
||
return vendors.Select(MapToDTO);
|
||
}
|
||
|
||
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
|
||
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 (items, totalCount) = await _vendorDataService.GetPagedAsync(
|
||
page,
|
||
pageSize,
|
||
search,
|
||
isActive,
|
||
companies,
|
||
trades,
|
||
locations,
|
||
jobBuckets);
|
||
return new PagedResult<VendorDTO>
|
||
{
|
||
Items = items.Select(MapToDTO).ToList(),
|
||
TotalCount = totalCount,
|
||
Page = page,
|
||
PageSize = pageSize
|
||
};
|
||
}
|
||
|
||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||
{
|
||
NormalizePhoneFields(dto);
|
||
var validationResult = await _createValidator.ValidateAsync(dto);
|
||
if (!validationResult.IsValid)
|
||
{
|
||
throw new ValidationException(validationResult.Errors);
|
||
}
|
||
|
||
var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId);
|
||
|
||
var vendor = new Vendor
|
||
{
|
||
CompanyName = dto.Name,
|
||
ContactName = dto.ContactName,
|
||
Email = dto.Email,
|
||
Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone),
|
||
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
||
PreferredContact = dto.PreferredContact,
|
||
Address = dto.Address,
|
||
City = dto.City,
|
||
State = dto.State,
|
||
Zip = dto.Zipcode,
|
||
TradeSpecialties = dto.TradeSpecialties,
|
||
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||
Notes = dto.Notes,
|
||
IsActive = dto.IsActive,
|
||
CompanyId = company?.Id,
|
||
CreatedDate = DateTime.UtcNow,
|
||
createdby = userId
|
||
};
|
||
|
||
ApplyCompanyFields(vendor, company);
|
||
|
||
var created = await _vendorDataService.AddAsync(vendor);
|
||
return MapToDTO(created);
|
||
}
|
||
|
||
public async Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId)
|
||
{
|
||
NormalizePhoneFields(dto);
|
||
var validationResult = await _updateValidator.ValidateAsync(dto);
|
||
if (!validationResult.IsValid)
|
||
{
|
||
throw new ValidationException(validationResult.Errors);
|
||
}
|
||
|
||
var vendor = await _vendorDataService.GetByIdAsync(id);
|
||
if (vendor == null)
|
||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||
|
||
if (dto.IsActive.HasValue && !dto.IsActive.Value)
|
||
await AssertNoOpenLinkedWorkOrdersAsync(id);
|
||
|
||
if (dto.Name != null) vendor.CompanyName = dto.Name;
|
||
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
|
||
if (dto.Email != null) vendor.Email = dto.Email;
|
||
if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||
if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact;
|
||
if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties;
|
||
if (dto.Notes != null) vendor.Notes = dto.Notes;
|
||
if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value;
|
||
|
||
if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto))
|
||
{
|
||
var company = await ResolveCompanyAsync(
|
||
dto.CompanyId ?? vendor.CompanyId,
|
||
dto.Name ?? vendor.CompanyName,
|
||
dto,
|
||
userId);
|
||
vendor.CompanyId = company?.Id;
|
||
ApplyCompanyFields(vendor, company);
|
||
}
|
||
|
||
vendor.LastModificationTime = DateTime.UtcNow;
|
||
if (int.TryParse(userId, out int userIdInt))
|
||
vendor.LastModifierUserId = userIdInt;
|
||
|
||
await _vendorDataService.UpdateAsync(vendor);
|
||
return MapToDTO(vendor);
|
||
}
|
||
|
||
public async Task DeleteVendorAsync(int id, string userId)
|
||
{
|
||
var vendor = await _vendorDataService.GetByIdAsync(id);
|
||
if (vendor == null)
|
||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||
|
||
await AssertNoOpenLinkedWorkOrdersAsync(id);
|
||
|
||
vendor.IsActive = false;
|
||
vendor.LastModificationTime = DateTime.UtcNow;
|
||
if (int.TryParse(userId, out int userIdInt))
|
||
vendor.LastModifierUserId = userIdInt;
|
||
|
||
await _vendorDataService.UpdateAsync(vendor);
|
||
}
|
||
|
||
public async Task<bool> VendorExistsAsync(int id)
|
||
{
|
||
return await _vendorDataService.ExistsAsync(id);
|
||
}
|
||
|
||
public async Task<int> GetTotalVendorCountAsync()
|
||
{
|
||
return await _vendorDataService.CountAsync();
|
||
}
|
||
|
||
public async Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId)
|
||
{
|
||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
|
||
|
||
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
||
var openWorkOrders = linked
|
||
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
|
||
.Select(MapToLinkedWorkOrderDTO)
|
||
.ToList();
|
||
|
||
return new VendorDeactivationImpactDTO
|
||
{
|
||
VendorId = vendorId,
|
||
CanDeactivate = openWorkOrders.Count == 0,
|
||
OpenWorkOrders = openWorkOrders
|
||
};
|
||
}
|
||
|
||
public async Task<VendorDTO> UpdateVendorFromWorkOrderAsync(
|
||
int vendorId,
|
||
WorkOrderVendorUpdateDTO dto,
|
||
string userId)
|
||
{
|
||
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||
var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto);
|
||
if (!validationResult.IsValid)
|
||
{
|
||
throw new ValidationException(validationResult.Errors);
|
||
}
|
||
|
||
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
|
||
if (vendor == null)
|
||
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
|
||
|
||
var isAssigned = await _vendorDataService.IsVendorAssignedToWorkOrderAsync(vendorId, dto.WorkOrderId);
|
||
if (!isAssigned)
|
||
throw new InvalidOperationException(
|
||
$"Vendor {vendorId} is not actively linked to work order {dto.WorkOrderId}.");
|
||
|
||
var changes = new List<VendorAuditLog>();
|
||
var now = DateTime.UtcNow;
|
||
|
||
void RecordChange(string fieldName, string? oldValue, string? newValue)
|
||
{
|
||
if (!string.Equals(oldValue, newValue, StringComparison.Ordinal))
|
||
{
|
||
changes.Add(new VendorAuditLog
|
||
{
|
||
VendorId = vendorId,
|
||
WorkOrderId = dto.WorkOrderId,
|
||
FieldName = fieldName,
|
||
OldValue = oldValue,
|
||
NewValue = newValue,
|
||
Actor = userId,
|
||
CreatedAt = now
|
||
});
|
||
}
|
||
}
|
||
|
||
if (dto.ContactName != null)
|
||
{
|
||
RecordChange(nameof(Vendor.ContactName), vendor.ContactName, dto.ContactName);
|
||
vendor.ContactName = dto.ContactName;
|
||
}
|
||
|
||
if (dto.PreferredContact != null)
|
||
{
|
||
RecordChange(nameof(Vendor.PreferredContact), vendor.PreferredContact, dto.PreferredContact);
|
||
vendor.PreferredContact = dto.PreferredContact;
|
||
}
|
||
|
||
if (dto.Phone != null)
|
||
{
|
||
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||
RecordChange(nameof(Vendor.Phone), vendor.Phone, normalized);
|
||
vendor.Phone = normalized;
|
||
}
|
||
|
||
if (dto.Email != null)
|
||
{
|
||
RecordChange(nameof(Vendor.Email), vendor.Email, dto.Email);
|
||
vendor.Email = dto.Email;
|
||
}
|
||
|
||
if (dto.Notes != null)
|
||
{
|
||
RecordChange(nameof(Vendor.Notes), vendor.Notes, dto.Notes);
|
||
vendor.Notes = dto.Notes;
|
||
}
|
||
|
||
vendor.LastModificationTime = now;
|
||
if (int.TryParse(userId, out int userIdInt))
|
||
vendor.LastModifierUserId = userIdInt;
|
||
|
||
await _vendorDataService.UpdateWithAuditLogsAsync(vendor, changes);
|
||
|
||
return MapToDTO(vendor);
|
||
}
|
||
|
||
private async Task<VendorCompany?> ResolveCompanyAsync(
|
||
int? requestedCompanyId,
|
||
string? companyName,
|
||
object dto,
|
||
string userId)
|
||
{
|
||
if (requestedCompanyId.HasValue)
|
||
{
|
||
var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value);
|
||
if (byId != null)
|
||
{
|
||
ApplyCompanyUpdates(byId, companyName, dto, userId);
|
||
await _vendorDataService.UpdateCompanyAsync(byId);
|
||
return byId;
|
||
}
|
||
|
||
throw new ValidationException(new[]
|
||
{
|
||
new ValidationFailure(
|
||
nameof(CreateVendorDTO.CompanyId),
|
||
$"No vendor company exists with ID {requestedCompanyId.Value}.")
|
||
});
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(companyName))
|
||
return null;
|
||
|
||
var normalizedName = companyName.Trim().ToLowerInvariant();
|
||
var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName);
|
||
if (existing != null)
|
||
{
|
||
ApplyCompanyUpdates(existing, companyName, dto, userId);
|
||
await _vendorDataService.UpdateCompanyAsync(existing);
|
||
return existing;
|
||
}
|
||
|
||
string? companyPhone = null;
|
||
string? email = null;
|
||
string? address = null;
|
||
string? city = null;
|
||
string? state = null;
|
||
string? zip = null;
|
||
string? mapsUrl = null;
|
||
|
||
if (dto is CreateVendorDTO create)
|
||
{
|
||
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(create.CompanyPhone);
|
||
email = create.Email;
|
||
address = create.Address;
|
||
city = create.City;
|
||
state = create.State;
|
||
zip = create.Zipcode;
|
||
mapsUrl = create.GoogleMapsUrl;
|
||
}
|
||
else if (dto is UpdateVendorDTO update)
|
||
{
|
||
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(update.CompanyPhone);
|
||
email = update.Email;
|
||
address = update.Address;
|
||
city = update.City;
|
||
state = update.State;
|
||
zip = update.Zipcode;
|
||
mapsUrl = update.GoogleMapsUrl;
|
||
}
|
||
|
||
var company = new VendorCompany
|
||
{
|
||
Name = companyName.Trim(),
|
||
NormalizedName = normalizedName,
|
||
CompanyPhone = companyPhone,
|
||
Email = email,
|
||
Address = address,
|
||
City = city,
|
||
State = state,
|
||
Zip = zip,
|
||
GoogleMapsUrl = mapsUrl,
|
||
CreatedDate = DateTime.UtcNow,
|
||
createdby = userId
|
||
};
|
||
|
||
return await _vendorDataService.AddCompanyAsync(company);
|
||
}
|
||
|
||
private static void ApplyCompanyFields(Vendor vendor, VendorCompany? company)
|
||
{
|
||
if (company == null)
|
||
return;
|
||
|
||
if (company.CompanyPhone != null) vendor.CompanyPhone = company.CompanyPhone;
|
||
if (company.Address != null) vendor.Address = company.Address;
|
||
if (company.City != null) vendor.City = company.City;
|
||
if (company.State != null) vendor.State = company.State;
|
||
if (company.Zip != null) vendor.Zip = company.Zip;
|
||
if (company.GoogleMapsUrl != null) vendor.GoogleMapsUrl = company.GoogleMapsUrl;
|
||
}
|
||
|
||
private static bool HasCompanyFieldUpdate(UpdateVendorDTO dto) =>
|
||
dto.CompanyPhone != null ||
|
||
dto.Address != null ||
|
||
dto.City != null ||
|
||
dto.State != null ||
|
||
dto.Zipcode != null ||
|
||
dto.GoogleMapsUrl != null;
|
||
|
||
private static void ApplyCompanyUpdates(
|
||
VendorCompany company,
|
||
string? companyName,
|
||
object dto,
|
||
string userId)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(companyName))
|
||
{
|
||
company.Name = companyName.Trim();
|
||
company.NormalizedName = company.Name.ToLowerInvariant();
|
||
}
|
||
|
||
if (dto is CreateVendorDTO create)
|
||
{
|
||
if (create.CompanyPhone != null) company.CompanyPhone = create.CompanyPhone;
|
||
if (create.Address != null) company.Address = create.Address;
|
||
if (create.City != null) company.City = create.City;
|
||
if (create.State != null) company.State = create.State;
|
||
if (create.Zipcode != null) company.Zip = create.Zipcode;
|
||
if (create.GoogleMapsUrl != null) company.GoogleMapsUrl = create.GoogleMapsUrl;
|
||
}
|
||
else if (dto is UpdateVendorDTO update)
|
||
{
|
||
if (update.CompanyPhone != null) company.CompanyPhone = update.CompanyPhone;
|
||
if (update.Address != null) company.Address = update.Address;
|
||
if (update.City != null) company.City = update.City;
|
||
if (update.State != null) company.State = update.State;
|
||
if (update.Zipcode != null) company.Zip = update.Zipcode;
|
||
if (update.GoogleMapsUrl != null) company.GoogleMapsUrl = update.GoogleMapsUrl;
|
||
}
|
||
|
||
company.LastModificationTime = DateTime.UtcNow;
|
||
if (int.TryParse(userId, out int userIdInt))
|
||
company.LastModifierUserId = userIdInt;
|
||
}
|
||
|
||
private static void NormalizePhoneFields(CreateVendorDTO dto)
|
||
{
|
||
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
|
||
}
|
||
|
||
private static void NormalizePhoneFields(UpdateVendorDTO dto)
|
||
{
|
||
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
|
||
}
|
||
|
||
private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId)
|
||
{
|
||
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
||
var openWorkOrders = linked
|
||
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
|
||
.Select(MapToLinkedWorkOrderDTO)
|
||
.ToList();
|
||
|
||
if (openWorkOrders.Count > 0)
|
||
{
|
||
throw new VendorDeactivationBlockedException(
|
||
"Vendor cannot be deactivated while linked work orders are open or scheduled.",
|
||
openWorkOrders);
|
||
}
|
||
}
|
||
|
||
private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus)
|
||
{
|
||
if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled)
|
||
return true;
|
||
|
||
if (!string.IsNullOrWhiteSpace(legacyStatus) &&
|
||
TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant()))
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
private static LinkedWorkOrderDTO MapToLinkedWorkOrderDTO(LinkedWorkOrderInfo info) => new()
|
||
{
|
||
WorkOrderId = info.WorkOrderId,
|
||
WorkOrderNumber = info.WorkOrderNumber,
|
||
WorkOrderTitle = info.WorkOrderTitle,
|
||
Status = info.Status,
|
||
ScheduledDate = info.ScheduledDate,
|
||
DispatchId = info.DispatchId
|
||
};
|
||
|
||
private VendorDTO MapToDTO(Vendor vendor)
|
||
{
|
||
return new VendorDTO
|
||
{
|
||
Id = vendor.Id,
|
||
Name = vendor.CompanyName,
|
||
CompanyId = vendor.CompanyId,
|
||
ContactName = vendor.ContactName,
|
||
Email = vendor.Email,
|
||
Phone = vendor.Phone,
|
||
CompanyPhone = vendor.CompanyPhone,
|
||
PreferredContact = vendor.PreferredContact,
|
||
Address = vendor.Address,
|
||
City = vendor.City,
|
||
State = vendor.State,
|
||
Zipcode = vendor.Zip,
|
||
TradeSpecialties = vendor.TradeSpecialties,
|
||
GoogleMapsUrl = vendor.GoogleMapsUrl,
|
||
Notes = vendor.Notes,
|
||
IsActive = vendor.IsActive,
|
||
TotalJobs = vendor.Dispatches == null
|
||
? vendor.TotalJobs
|
||
: CountDistinctWorkOrders(vendor)
|
||
};
|
||
}
|
||
|
||
private static int CountDistinctWorkOrders(Vendor vendor)
|
||
{
|
||
if (vendor.Dispatches == null)
|
||
return 0;
|
||
|
||
return vendor.Dispatches
|
||
.SelectMany(dispatch =>
|
||
{
|
||
var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId)
|
||
?? Enumerable.Empty<int>();
|
||
return dispatch.WorkOrderId.HasValue
|
||
? linked.Append(dispatch.WorkOrderId.Value)
|
||
: linked;
|
||
})
|
||
.Distinct()
|
||
.Count();
|
||
}
|
||
|
||
public async Task<IEnumerable<VendorDropdownItemDTO>> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken)
|
||
{
|
||
var vendors = await _vendorDataService.GetActiveVendorsAsync(cancellationToken);
|
||
|
||
var result = vendors.Select(v =>
|
||
{
|
||
var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip);
|
||
var addr = new[] { v.Address, v.City, v.State, v.Zip }
|
||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||
return new VendorDropdownItemDTO
|
||
{
|
||
Id = v.Id,
|
||
CompanyName = v.CompanyName,
|
||
ContactName = v.ContactName,
|
||
PreferredContact = v.PreferredContact,
|
||
TradeSpecialties = v.TradeSpecialties,
|
||
Address = string.Join(", ", addr),
|
||
DistanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
|
||
};
|
||
}).ToList();
|
||
|
||
if (!string.IsNullOrWhiteSpace(trade))
|
||
{
|
||
var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList();
|
||
var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList();
|
||
result = tradeMatched.Concat(rest).ToList();
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(siteZip))
|
||
{
|
||
result = result.OrderBy(v => v.DistanceMiles ?? 99999).ToList();
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
public async Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||
{
|
||
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
|
||
|
||
var companies = vendors
|
||
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
|
||
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||
.Select(group => group.First())
|
||
.OrderBy(v => v.CompanyName)
|
||
.Select(v => new VendorFacetCompanyDTO
|
||
{
|
||
Name = v.CompanyName,
|
||
CompanyId = v.CompanyId,
|
||
CompanyPhone = v.CompanyPhone,
|
||
Email = v.Email,
|
||
PreferredContact = v.PreferredContact,
|
||
Address = v.Address,
|
||
City = v.City,
|
||
State = v.State,
|
||
Zip = v.Zip,
|
||
GoogleMapsUrl = v.GoogleMapsUrl
|
||
});
|
||
|
||
var trades = vendors
|
||
.SelectMany(v => (v.TradeSpecialties ?? "")
|
||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(value => value);
|
||
|
||
var locations = vendors
|
||
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
|
||
.Select(v => new VendorFacetLocationDTO
|
||
{
|
||
City = v.City,
|
||
State = v.State,
|
||
Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||
})
|
||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(location => location.Label);
|
||
|
||
return new VendorFacetsDTO
|
||
{
|
||
Companies = companies,
|
||
Trades = trades,
|
||
Locations = locations,
|
||
JobBuckets = new[]
|
||
{
|
||
new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" },
|
||
new VendorJobBucketDTO { Id = "50-99", Label = "50–99" },
|
||
new VendorJobBucketDTO { Id = "100-149", Label = "100–149" },
|
||
new VendorJobBucketDTO { Id = "150-plus", Label = "150+" }
|
||
}
|
||
};
|
||
}
|
||
|
||
public async Task<VendorPortalTokenDTO?> GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken)
|
||
{
|
||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||
return null;
|
||
|
||
var token = await _vendorTokenService.GetOrCreateActiveTokenAsync(vendorId, cancellationToken);
|
||
return BuildPortalTokenResponse(token);
|
||
}
|
||
|
||
public async Task<VendorPortalTokenDTO?> RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken)
|
||
{
|
||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||
return null;
|
||
|
||
var token = await _vendorTokenService.RotateAsync(vendorId, cancellationToken);
|
||
return BuildPortalTokenResponse(token);
|
||
}
|
||
|
||
public async Task<bool> RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken)
|
||
{
|
||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||
return false;
|
||
|
||
await _vendorTokenService.RevokeAllAsync(vendorId, cancellationToken);
|
||
return true;
|
||
}
|
||
|
||
private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token)
|
||
{
|
||
var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
|
||
return new VendorPortalTokenDTO
|
||
{
|
||
Token = token.Token,
|
||
IssuedAt = token.IssuedAt,
|
||
ExpiresAt = token.ExpiresAt,
|
||
LastUsedAt = token.LastUsedAt,
|
||
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
|
||
};
|
||
}
|
||
}
|
||
}
|