shoc-backend/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs
Alexandre Brandizzi 669e9b2932
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions
feat(vendors): add company roster management (SH-198) (#48)
* 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
2026-08-03 17:53:24 -03:00

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()
};
}
}