using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using FluentValidation.Results; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Globalization; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; 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 const int MaxDirectoryPageSize = 100; 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; private readonly ILogger _logger; public VendorService( IVendorDataService vendorDataService, IVendorPortalTokenService vendorTokenService, IZipCodeDistance zipDistance, IOptions frontendOptions, ICreateVendorValidation createValidator, IUpdateVendorValidation updateValidator, IWorkOrderVendorUpdateValidation workOrderUpdateValidator, ILogger logger) { _vendorDataService = vendorDataService; _vendorTokenService = vendorTokenService; _zipDistance = zipDistance; _frontendOptions = frontendOptions.Value; _createValidator = createValidator; _updateValidator = updateValidator; _workOrderUpdateValidator = workOrderUpdateValidator; _logger = logger; } public async Task GetVendorByIdAsync(int id) { var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id); return vendor == null ? null : MapToDTO(vendor); } public async Task GetVendorByIdWithDetailsAsync(int id) { var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id); return vendor == null ? null : MapToDTO(vendor); } public async Task> GetAllVendorsAsync() { var vendors = await _vendorDataService.GetAllAsync(); return vendors.Select(MapToDTO); } public async Task> GetVendorsPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null) { var (items, totalCount) = await _vendorDataService.GetPagedAsync( page, pageSize, search, isActive, companies, trades, locations, jobBuckets); return new PagedResult { Items = items.Select(MapToDTO).ToList(), TotalCount = totalCount, Page = page, PageSize = pageSize }; } // SH-198 legacy per-technician directory contract (GetVendorList): raw // pagination semantics are part of the compatibility surface, so pageSize // is forwarded unclamped. public async Task> GetVendorTechnicianDirectoryPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, CancellationToken cancellationToken = default) { var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync( page, pageSize, search, isActive, companies, trades, locations, jobBuckets, cancellationToken); return new PagedResult { Items = items.Select(MapToDirectoryItem).ToList(), TotalCount = totalCount, Page = page, PageSize = pageSize }; } private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new() { Id = row.VendorId, CompanyId = row.CompanyId == 0 ? null : row.CompanyId, CompanyName = row.CompanyName, ContactName = row.ContactName, Email = row.Email, Phone = row.Phone, CompanyPhone = row.CompanyPhone, PreferredContact = row.PreferredContact, Address = row.Address, City = row.City, State = row.State, Zipcode = row.Zip, TradeSpecialties = row.TradeSpecialties, GoogleMapsUrl = row.GoogleMapsUrl, Notes = row.Notes, IsActive = row.IsActive, TotalJobs = row.TotalJobs }; // SH-281 company-grouped directory (GetVendorDirectoryList). public async Task> GetVendorCompanyDirectoryPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null, IReadOnlyCollection? areas = null, CancellationToken cancellationToken = default) { var normalizedPage = Math.Max(1, page); var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize); var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken); var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync( normalizedPage, normalizedPageSize, search, isActive, companies, trades, locations, jobBuckets, areaFilter, cancellationToken); return new PagedResult { Items = items.Select(MapToCompanyGroup).ToList(), TotalCount = totalCount, Page = normalizedPage, PageSize = normalizedPageSize }; } // SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects // companies without an Area; other values must be stable positive integer ids // of active catalogue rows. Display names, unknown ids and archived ids never // error — they simply match nobody. Null means no Area filter was requested. private async Task ResolveAreaFilterAsync( IReadOnlyCollection? areas, CancellationToken cancellationToken) { var values = areas? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim()) .ToList(); if (values is not { Count: > 0 }) return null; var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal); var requestedIds = new HashSet(); foreach (var value in values) { if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0) requestedIds.Add(id); } IReadOnlyCollection activeIds = Array.Empty(); if (requestedIds.Count > 0) { var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken); activeIds = activeAreas .Select(area => area.Id) .Where(requestedIds.Contains) .ToArray(); } return new VendorAreaFilter { AreaIds = activeIds, IncludeUnassigned = includeUnassigned }; } private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new() { Id = group.Id, CompanyId = group.CompanyId, CompanyName = group.CompanyName, ContactName = group.ContactName, Email = group.Email, Phone = group.Phone, CompanyPhone = group.CompanyPhone, PreferredContact = group.PreferredContact, Address = group.Address, City = group.City, State = group.State, Zipcode = group.Zip, TradeSpecialties = group.TradeSpecialties, GoogleMapsUrl = group.GoogleMapsUrl, Notes = group.Notes, AreaId = group.AreaId, AreaName = group.AreaName, IsActive = group.IsActive, TotalJobs = group.TotalJobs, Technicians = group.Technicians.Select(MapToTechnician).ToList() }; private static VendorDirectoryTechnicianDTO MapToTechnician(VendorCompanyGroupTechnician technician) => new() { Id = technician.Id, ContactName = technician.ContactName, Email = technician.Email, Phone = technician.Phone, PreferredContact = technician.PreferredContact, TradeSpecialties = technician.TradeSpecialties, IsActive = technician.IsActive, TotalJobs = technician.TotalJobs }; public async Task 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 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) { if (dto.ConfirmOpenWorkOrders) await LogConfirmedDeactivationAsync(id, userId); else 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, bool confirmOpenWorkOrders) { var vendor = await _vendorDataService.GetByIdAsync(id); if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); if (confirmOpenWorkOrders) await LogConfirmedDeactivationAsync(id, userId); else 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 VendorExistsAsync(int id) { return await _vendorDataService.ExistsAsync(id); } public async Task GetTotalVendorCountAsync() { return await _vendorDataService.CountAsync(); } public async Task 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 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(); 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 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); } // SH-44's story is about not *silently* orphaning active work. When the caller // confirms past the guard, the work orders it chose to leave open are recorded. private async Task LogConfirmedDeactivationAsync(int vendorId, string userId) { var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId); var openCount = linked.Count(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)); if (openCount == 0) return; _logger.LogWarning( "Vendor {VendorId} deactivated by user {UserId} with {OpenWorkOrderCount} open work orders, confirmed by the caller.", vendorId, userId, openCount); } // Guard for the unconfirmed path only. SH-44 and SH-82 left "blocks or requires // explicit confirmation" to be settled with the team; SH-254 settles it as // explicit confirmation, so a caller that has not confirmed is still blocked. 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(); return dispatch.WorkOrderId.HasValue ? linked.Append(dispatch.WorkOrderId.Value) : linked; }) .Distinct() .Count(); } public async Task> 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 GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) { // Companies and Locations are sourced from company-owned VendorCompany fields // (status semantics aligned to the directory union). Trades come from the // SH-249 server-owned canonical Trade reference data — a stable list that // does not derive from technician TradeSpecialties free text, so it is // populated even on an empty database and independent of the status filter. var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken); var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken); var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken); var companyFacets = companies .Where(c => !string.IsNullOrWhiteSpace(c.Name)) .GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) .Select(c => new VendorFacetCompanyDTO { Name = c.Name, CompanyId = c.Id, CompanyPhone = c.CompanyPhone, Email = c.Email, PreferredContact = null, Address = c.Address, City = c.City, State = c.State, Zip = c.Zip, GoogleMapsUrl = c.GoogleMapsUrl }); var trades = canonicalTrades.Select(t => t.Name).ToList(); // Exact Location label rule (mirrors the directory filter): City/State // whenever either structured value is present, otherwise Address. // Address-only companies are therefore represented in the facets. var locations = companies .Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State) || !string.IsNullOrWhiteSpace(c.Address)) .Select(c => new VendorFacetLocationDTO { City = c.City, State = c.State, Label = !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State) ? string.Join(", ", new[] { c.City?.Trim(), c.State?.Trim() } .Where(value => !string.IsNullOrWhiteSpace(value))) : c.Address!.Trim() }) .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) .OrderBy(location => location.Label); // SH-278: the active Area catalogue, A–Z case-insensitive, independent of the // status filter and of whether any company currently uses an Area. Clients // prepend the "__unassigned__" option themselves. var areas = activeAreas .OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase) .Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name }) .ToList(); return new VendorFacetsDTO { Companies = companyFacets, Trades = trades, Areas = areas, 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 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 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 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" }; } } }