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 const int MaxNotesLength = 500; private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters."; private readonly IVendorCompanyRosterDataService _rosterDataService; public VendorCompanyRosterService(IVendorCompanyRosterDataService rosterDataService) { _rosterDataService = rosterDataService; } public async Task 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 CreateRosterAsync( CreateVendorRosterDTO dto, string userId, CancellationToken cancellationToken) { EnsureAuthenticated(userId); dto.Technicians ??= new List(); NormalizeAndValidateCompanyFields( dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Notes, nameof(CreateVendorRosterDTO.Notes), 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 ReconcileRosterAsync( int companyId, ReconcileVendorRosterDTO dto, string userId, CancellationToken cancellationToken) { EnsureAuthenticated(userId); dto.Technicians ??= new List(); NormalizeAndValidateCompanyFields( dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Notes, nameof(ReconcileVendorRosterDTO.Notes), 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 AddTechniciansAsync( int companyId, AddTechniciansVendorRosterDTO dto, string userId, CancellationToken cancellationToken) { EnsureAuthenticated(userId); dto.AddTechnicians ??= new List(); byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion); var failures = new List(); // 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, string? notes, string notesPropertyName, List technicians) { var failures = new List(); 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.")); ValidateNotes(notes, notesPropertyName, failures); // 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 technicians, List 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 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.")); ValidateNotes( fields.Notes, $"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.Notes)}", failures); 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 static void ValidateNotes( string? notes, string propertyName, List failures) { if (notes?.Length > MaxNotesLength) failures.Add(new ValidationFailure(propertyName, NotesMaxLengthMessage)); } private async Task EnsureTechnicianIdsBelongToCompanyAsync( int companyId, List 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 MapTechnicians(List 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() }; } }