shoc-backend/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs
Alexandre Brandizzi 9ef2512e14 fix(vendor-roster): conflict on colliding rename, reject no-op company update (SH-250)
Two review findings on the additive PATCH path:

- AddTechniciansAsync can rename via CompanyFields.Name and write NormalizedName
  against the unique index, but the save had no guard. A colliding rename
  surfaced as an unhandled 500 from the PATCH action instead of a stable client
  conflict. Pre-check the normalized name against other live companies and throw
  VendorRosterDuplicateNameException, with a scoped catch around the save for the
  race where a competing rename commits in between. The controller maps it to a
  409 alongside the existing concurrency conflict.
- Empty-payload validation only rejected a null CompanyFields, so an all-blank
  CompanyFields object was forwarded as a company update, bumping RowVersion and
  rewriting every technician's LastModificationTime without changing any company
  data. Blank fields now collapse to no company change, and a request with
  neither technicians nor a real company value fails validation.
2026-08-18 17:33:16 -03:00

425 lines
19 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);
}
public async Task<VendorRosterDTO> AddTechniciansAsync(
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
dto.AddTechnicians ??= new List<RosterTechnicianInputDTO>();
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
var failures = new List<ValidationFailure>();
// Additive contract (SH-250): added technicians are always new rows, so an
// id from this or any other company is rejected instead of silently
// mutating an existing technician.
if (dto.AddTechnicians.Any(technician => technician.Id.HasValue))
failures.Add(new ValidationFailure(
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
"Technician ids are not allowed when adding technicians."));
var hasCompanyChange = dto.CompanyFields != null && HasAnyCompanyValue(dto.CompanyFields);
if (dto.AddTechnicians.Count == 0 && !hasCompanyChange)
failures.Add(new ValidationFailure(
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
"At least one technician to add or a company update is required."));
NormalizeAndValidateTechnicians(nameof(AddTechniciansVendorRosterDTO.AddTechnicians), dto.AddTechnicians, failures);
VendorRosterCompanyFieldsWriteModel? companyFields = null;
if (dto.CompanyFields != null)
{
companyFields = ValidateAndMapCompanyFields(dto.CompanyFields, failures);
// SH-250: blank company fields all map to null ("unchanged"), so an empty
// or all-blank CompanyFields object carries no company update. Forwarding
// it as a non-null write model made the data layer bump RowVersion and
// rewrite every technician's LastModificationTime for a no-op request.
if (HasNoCompanyChange(companyFields))
companyFields = null;
}
if (failures.Count > 0)
throw new ValidationException(failures);
// The contact-group invariant ("at least one company phone or email") is
// preserved by construction: blank company fields map to null ("unchanged"),
// so this endpoint can only set valid phone/email values, never clear them.
var writeModel = new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = rowVersion,
ActorUserId = userId,
CompanyFields = companyFields,
AddTechnicians = MapTechnicians(dto.AddTechnicians)
};
var saved = await _rosterDataService.AddTechniciansAsync(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.
NormalizeAndValidateTechnicians(nameof(CreateVendorRosterDTO.Technicians), technicians, failures);
// 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);
}
// Normalizes technician phones in place and collects format failures using the
// supplied property-name prefix so each endpoint reports its own payload path.
private static void NormalizeAndValidateTechnicians(
string techniciansPropertyName,
List<RosterTechnicianInputDTO> technicians,
List<ValidationFailure> failures)
{
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(
$"{techniciansPropertyName}[{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(
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
"Invalid email address."));
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
failures.Add(new ValidationFailure(
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
"PreferredContact must be one of: Phone, Email, Text"));
}
}
// Validates optional company-level updates for the additive path and maps them
// to the write model. A null (or blank) field means "leave unchanged"; blank
// values normalize to null so a partial update can never clear data by accident.
private static bool HasAnyCompanyValue(VendorRosterCompanyFieldsDTO fields) =>
!string.IsNullOrWhiteSpace(fields.Name)
|| !string.IsNullOrWhiteSpace(fields.CompanyPhone)
|| !string.IsNullOrWhiteSpace(fields.Email)
|| !string.IsNullOrWhiteSpace(fields.Address)
|| !string.IsNullOrWhiteSpace(fields.City)
|| !string.IsNullOrWhiteSpace(fields.State)
|| !string.IsNullOrWhiteSpace(fields.Zip)
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|| !string.IsNullOrWhiteSpace(fields.Notes);
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
model.Name == null
&& model.CompanyPhone == null
&& model.Email == null
&& model.Address == null
&& model.City == null
&& model.State == null
&& model.Zip == null
&& model.GoogleMapsUrl == null
&& model.Notes == null;
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
VendorRosterCompanyFieldsDTO fields,
List<ValidationFailure> failures)
{
var name = fields.Name?.Trim();
if (fields.Name != null && string.IsNullOrWhiteSpace(name))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.Name),
"Company name is required."));
var normalizedPhone = VendorPhoneNormalizer.NormalizeToCanonical(fields.CompanyPhone);
if (normalizedPhone != null && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedPhone))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.CompanyPhone),
"Company phone must be in North American format: (XXX) XXX-XXXX"));
if (!string.IsNullOrWhiteSpace(fields.Email) && !BeValidEmail(fields.Email))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.Email),
"Invalid email address."));
if (!string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) && !BeValidAbsoluteHttpsUrl(fields.GoogleMapsUrl))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.GoogleMapsUrl),
"Google Maps URL must be an absolute HTTPS URL."));
return new VendorRosterCompanyFieldsWriteModel
{
Name = name,
CompanyPhone = normalizedPhone,
Email = string.IsNullOrWhiteSpace(fields.Email) ? null : fields.Email,
Address = string.IsNullOrWhiteSpace(fields.Address) ? null : fields.Address,
City = string.IsNullOrWhiteSpace(fields.City) ? null : fields.City,
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes
};
}
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()
};
}
}