mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
* feat(vendors): add company roster management * fix(security): remove request-controlled write guards * fix(vendors): synchronize roster company fields * fix(vendors): source facets from companies
288 lines
12 KiB
C#
288 lines
12 KiB
C#
using FluentValidation;
|
|
using FluentValidation.Results;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.DataServices.Models;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
using SeaHaven.Services.Validation;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class VendorCompanyRosterService : IVendorCompanyRosterService
|
|
{
|
|
private readonly IVendorCompanyRosterDataService _rosterDataService;
|
|
|
|
public VendorCompanyRosterService(IVendorCompanyRosterDataService rosterDataService)
|
|
{
|
|
_rosterDataService = rosterDataService;
|
|
}
|
|
|
|
public async Task<VendorRosterDTO?> GetRosterAsync(
|
|
int? vendorId,
|
|
int? companyId,
|
|
string userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnsureAuthenticated(userId);
|
|
|
|
if (!vendorId.HasValue && !companyId.HasValue)
|
|
throw new ValidationException("A vendor id or company id is required.");
|
|
|
|
var roster = await _rosterDataService.GetRosterAsync(vendorId, companyId, cancellationToken);
|
|
return roster == null ? null : MapToDTO(roster);
|
|
}
|
|
|
|
public async Task<VendorRosterDTO> CreateRosterAsync(
|
|
CreateVendorRosterDTO dto,
|
|
string userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnsureAuthenticated(userId);
|
|
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
|
|
NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians);
|
|
|
|
if (dto.Technicians.Any(technician => technician.Id.HasValue))
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.Technicians),
|
|
"Technician ids are not allowed when creating a vendor company.")
|
|
});
|
|
|
|
var writeModel = new VendorCompanyRosterWriteModel
|
|
{
|
|
Name = dto.Name,
|
|
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
|
Email = dto.Email,
|
|
Address = dto.Address,
|
|
City = dto.City,
|
|
State = dto.State,
|
|
Zip = dto.Zip,
|
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
|
Notes = dto.Notes,
|
|
ActorUserId = userId,
|
|
Technicians = MapTechnicians(dto.Technicians)
|
|
};
|
|
|
|
var saved = await _rosterDataService.CreateRosterAsync(writeModel, cancellationToken);
|
|
return MapToDTO(saved);
|
|
}
|
|
|
|
public async Task<VendorRosterDTO> ReconcileRosterAsync(
|
|
int companyId,
|
|
ReconcileVendorRosterDTO dto,
|
|
string userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnsureAuthenticated(userId);
|
|
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
|
|
NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians);
|
|
|
|
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
|
|
|
|
// Mismatched ids: every non-null technician id must belong to this company.
|
|
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
|
|
|
|
var writeModel = new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = rowVersion,
|
|
Name = dto.Name,
|
|
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
|
Email = dto.Email,
|
|
Address = dto.Address,
|
|
City = dto.City,
|
|
State = dto.State,
|
|
Zip = dto.Zip,
|
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
|
Notes = dto.Notes,
|
|
ActorUserId = userId,
|
|
Technicians = MapTechnicians(dto.Technicians)
|
|
};
|
|
|
|
var saved = await _rosterDataService.SaveRosterAsync(writeModel, cancellationToken);
|
|
return MapToDTO(saved);
|
|
}
|
|
|
|
private static void EnsureAuthenticated(string userId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
throw new UnauthorizedAccessException("An authenticated user is required.");
|
|
}
|
|
|
|
private static void NormalizeAndValidateCompanyFields(
|
|
string name,
|
|
string? companyPhone,
|
|
string? email,
|
|
string? googleMapsUrl,
|
|
List<RosterTechnicianInputDTO> technicians)
|
|
{
|
|
var failures = new List<ValidationFailure>();
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Name), "Company name is required."));
|
|
|
|
// Contact group: the company must expose at least one contact channel.
|
|
var normalizedCompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(companyPhone);
|
|
if (string.IsNullOrWhiteSpace(normalizedCompanyPhone) && string.IsNullOrWhiteSpace(email))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.CompanyPhone),
|
|
"At least one company phone or email is required."));
|
|
|
|
if (!string.IsNullOrWhiteSpace(normalizedCompanyPhone) &&
|
|
!VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedCompanyPhone))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.CompanyPhone),
|
|
"Company phone must be in North American format: (XXX) XXX-XXXX"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(email) && !BeValidEmail(email))
|
|
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Email), "Invalid email address."));
|
|
|
|
if (!string.IsNullOrWhiteSpace(googleMapsUrl) && !BeValidAbsoluteHttpsUrl(googleMapsUrl))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.GoogleMapsUrl),
|
|
"Google Maps URL must be an absolute HTTPS URL."));
|
|
|
|
// Technician phones must be valid North American format when provided.
|
|
for (var i = 0; i < technicians.Count; i++)
|
|
{
|
|
var technician = technicians[i];
|
|
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
|
|
technician.Phone = normalized;
|
|
|
|
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
|
|
failures.Add(new ValidationFailure(
|
|
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
|
|
"Phone must be in North American format: (XXX) XXX-XXXX"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
|
|
failures.Add(new ValidationFailure(
|
|
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
|
|
"Invalid email address."));
|
|
|
|
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
|
|
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
|
|
failures.Add(new ValidationFailure(
|
|
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
|
|
"PreferredContact must be one of: Phone, Email, Text"));
|
|
}
|
|
|
|
// Duplicate technician ids are not allowed.
|
|
var duplicateIds = technicians
|
|
.Where(t => t.Id.HasValue)
|
|
.GroupBy(t => t.Id!.Value)
|
|
.Where(group => group.Count() > 1)
|
|
.Select(group => group.Key)
|
|
.ToList();
|
|
|
|
if (duplicateIds.Count > 0)
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.Technicians),
|
|
"Duplicate technician ids are not allowed."));
|
|
|
|
if (failures.Count > 0)
|
|
throw new ValidationException(failures);
|
|
}
|
|
|
|
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
|
|
int companyId,
|
|
List<RosterTechnicianInputDTO> technicians,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var requestedIds = technicians
|
|
.Where(t => t.Id.HasValue)
|
|
.Select(t => t.Id!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
if (requestedIds.Count == 0)
|
|
return;
|
|
|
|
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken);
|
|
if (roster == null)
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.Name), $"No vendor company exists with ID {companyId}.")
|
|
});
|
|
|
|
var validIds = roster.Technicians.Select(t => t.Id).ToHashSet();
|
|
var mismatched = requestedIds.Where(id => !validIds.Contains(id)).ToList();
|
|
|
|
if (mismatched.Count > 0)
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(
|
|
nameof(ReconcileVendorRosterDTO.Technicians),
|
|
$"Technician ids do not belong to company {companyId}: {string.Join(", ", mismatched)}.")
|
|
});
|
|
}
|
|
|
|
private static List<RosterTechnicianWriteModel> MapTechnicians(List<RosterTechnicianInputDTO> technicians)
|
|
{
|
|
return technicians.Select(t => new RosterTechnicianWriteModel
|
|
{
|
|
Id = t.Id,
|
|
ContactName = t.ContactName,
|
|
Phone = t.Phone,
|
|
Email = t.Email,
|
|
PreferredContact = t.PreferredContact,
|
|
TradeSpecialties = t.TradeSpecialties,
|
|
IsActive = t.IsActive ?? true
|
|
}).ToList();
|
|
}
|
|
|
|
private static byte[] ParseRequiredRowVersion(string? base64)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(base64))
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Row version is required.")
|
|
});
|
|
|
|
try
|
|
{
|
|
return Convert.FromBase64String(base64);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Invalid row version format.")
|
|
});
|
|
}
|
|
}
|
|
|
|
private static bool BeValidEmail(string? value) =>
|
|
System.Net.Mail.MailAddress.TryCreate(value, out var address) && address.Address == value;
|
|
|
|
private static bool BeValidAbsoluteHttpsUrl(string? value) =>
|
|
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
|
|
|
|
private static VendorRosterDTO MapToDTO(VendorCompanyRosterReadModel roster) => new()
|
|
{
|
|
CompanyId = roster.CompanyId,
|
|
Name = roster.Name,
|
|
CompanyPhone = roster.CompanyPhone,
|
|
Email = roster.Email,
|
|
Address = roster.Address,
|
|
City = roster.City,
|
|
State = roster.State,
|
|
Zip = roster.Zip,
|
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
|
Notes = roster.Notes,
|
|
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
|
|
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
|
|
{
|
|
Id = t.Id,
|
|
ContactName = t.ContactName,
|
|
Phone = t.Phone,
|
|
Email = t.Email,
|
|
PreferredContact = t.PreferredContact,
|
|
TradeSpecialties = t.TradeSpecialties,
|
|
IsActive = t.IsActive,
|
|
TotalJobs = t.TotalJobs
|
|
}).ToList()
|
|
};
|
|
}
|
|
}
|