using System.Security.Claims; using Data.SeaHavenIndustries; using FluentValidation; using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; namespace SeaHaven.Services.Implementation { /// /// Location service implementation /// Contains business logic, validation, and orchestration for Location operations /// public class LocationService : ILocationService { private readonly ILocationDataService _locationDataService; private readonly IAccountDataService _accountDataService; private readonly ICreateLocationValidation _createValidator; private readonly IUpdateLocationValidation _updateValidator; public LocationService( ILocationDataService locationDataService, IAccountDataService accountDataService, ICreateLocationValidation createValidator, IUpdateLocationValidation updateValidator) { _locationDataService = locationDataService; _accountDataService = accountDataService; _createValidator = createValidator; _updateValidator = updateValidator; } // Query operations public async Task GetLocationByIdAsync(int id) { var location = await _locationDataService.GetByIdAsync(id); return location == null ? null : MapToDTO(location); } public async Task GetLocationByIdWithDetailsAsync(int id) { var location = await _locationDataService.GetByIdWithDetailsAsync(id); return location == null ? null : MapToDTO(location); } public async Task> GetAllLocationsAsync() { var locations = await _locationDataService.GetAllAsync(); return locations.Select(l => MapToDTO(l)); } public async Task> GetLocationsByAccountIdAsync(int accountId) { var locations = await _locationDataService.GetByAccountIdAsync(accountId); return locations.Select(l => MapToDTO(l)); } public async Task> GetLocationsPagedAsync(int page, int pageSize, string? search = null) { var (items, totalCount) = await _locationDataService.GetPagedAsync(page, pageSize, search); return new PagedResult { Items = items.Select(l => MapToDTO(l)).ToList(), TotalCount = totalCount, Page = page, PageSize = pageSize }; } public async Task> GetSiteOptionsAsync( string? search = null, CancellationToken cancellationToken = default) { var rows = await _locationDataService.GetSiteOptionsAsync(search, cancellationToken); return rows .Select(r => new SiteOptionDTO { Id = r.Id, Code = r.Code, City = r.City, State = r.State }) .ToList(); } public async Task CreateLocationAsync(CreateLocationDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); if (!validationResult.IsValid) { throw new ValidationException(validationResult.Errors); } var location = new Locations { Name = dto.LocationName, Title = dto.LocationName, Address1 = dto.Address, City = dto.City, State = dto.State, Zip = dto.Zipcode, CreatedDate = DateTime.UtcNow, createdby = userId }; var created = await _locationDataService.AddAsync(location); return MapToDTO(created); } public async Task UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId) { var validationResult = await _updateValidator.ValidateAsync(dto); if (!validationResult.IsValid) { throw new ValidationException(validationResult.Errors); } var location = await _locationDataService.GetByIdAsync(id); if (location == null) throw new InvalidOperationException($"Location with ID {id} not found"); // Update properties if (dto.LocationName != null) location.Name = dto.LocationName; if (dto.LocationName != null) location.Title = dto.LocationName; if (dto.Address != null) location.Address1 = dto.Address; if (dto.City != null) location.City = dto.City; if (dto.State != null) location.State = dto.State; if (dto.Zipcode != null) location.Zip = dto.Zipcode; location.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) location.LastModifierUserId = userIdInt; await _locationDataService.UpdateAsync(location); return MapToDTO(location); } public async Task DeleteLocationAsync(int id, string userId) { var location = await _locationDataService.GetByIdAsync(id); if (location == null) throw new InvalidOperationException($"Location with ID {id} not found"); location.IsDeleted = true; location.DeleterUserId = userId; location.DeletionTime = DateTime.UtcNow; await _locationDataService.UpdateAsync(location); } public async Task LocationExistsAsync(int id) { return await _locationDataService.ExistsAsync(id); } public async Task GetTotalLocationCountAsync() { return await _locationDataService.CountAsync(); } public async Task> GetLocationListPagedAsync( int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default, string? sortBy = null, string? sortDirection = null) { var stateFilter = NormalizeStateFilter(states); var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter); var sort = NormalizeSortColumn(sortBy); var direction = NormalizeSortDirection(sortDirection); var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken, sort, direction); // SH-138: one bounded contact read for the whole page, never per row. var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken); return new PagedResult { Items = items.Select(l => MapToDTO(l, contactsByLocation)).ToList(), TotalCount = totalCount, Page = page, PageSize = pageSize }; } internal static readonly IReadOnlyCollection SortableColumns = new[] { "code", "client", "address", "city", "state", "poc" }; internal static string? NormalizeSortColumn(string? sortBy) { if (string.IsNullOrWhiteSpace(sortBy)) return null; var normalized = sortBy.Trim().ToLowerInvariant(); if (!SortableColumns.Contains(normalized)) { throw new ValidationException(new[] { new ValidationFailure("sortBy", $"sortBy must be one of: {string.Join(", ", SortableColumns)}.") }); } return normalized; } internal static string NormalizeSortDirection(string? sortDirection) { if (string.IsNullOrWhiteSpace(sortDirection)) return "asc"; var normalized = sortDirection.Trim().ToLowerInvariant(); if (normalized != "asc" && normalized != "desc") { throw new ValidationException(new[] { new ValidationFailure("sortDirection", "sortDirection must be asc or desc.") }); } return normalized; } internal static IReadOnlyCollection? NormalizeStateFilter(string? states) { if (string.IsNullOrWhiteSpace(states)) return null; var normalized = states .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(code => code.ToUpperInvariant()) .Distinct(StringComparer.Ordinal) .ToList(); if (normalized.Count == 0) return null; var invalid = normalized.Where(code => !UsStateCodes.All.Contains(code)).ToList(); if (invalid.Count > 0) { throw new ValidationException(new[] { new ValidationFailure("states", $"states must be valid US postal abbreviations: {string.Join(", ", invalid)}.") }); } return normalized; } public async Task GetLocationDetailAsync(int id, CancellationToken cancellationToken) { var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken); return location == null ? null : MapToDTO(location); } public async Task CreateLocationFromRequestAsync( LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken) { await EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken); var location = new Locations { Name = request.Name, Title = request.Title, Address1 = request.Address, City = request.City, State = request.State, Zip = request.ZipCode, PhoneNumber = request.Phone, Email = request.ContactEmail, Status = request.Status, AccountId = request.AccountId }; if (request.Contacts is List contacts) { ApplySiteContactsForCreate(location, contacts, GetActorId(user)); } await _locationDataService.AddAsync(location, cancellationToken); } public async Task UpdateLocationFromRequestAsync( int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken) { var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken); if (location == null) throw new KeyNotFoundException($"Location with ID {id} not found"); EnsureLocationInCallerScope(user, location.AccountId); location.Name = request.Name; location.Title = request.Title; location.Address1 = request.Address; location.City = request.City; location.State = request.State; location.Zip = request.ZipCode; location.PhoneNumber = request.Phone; location.Email = request.ContactEmail; location.Status = request.Status; if (request.AccountId is int accountId) { await EnsureAccountAssignableAsync(user, accountId, location.AccountId, cancellationToken); location.AccountId = accountId; } // SH-138: null contacts keeps legacy behavior and must not mutate contact rows. if (request.Contacts is List contacts) { ApplySiteContactsForUpdate(location, contacts, GetActorId(user)); } await _locationDataService.UpdateAsync(location, cancellationToken); } public Task DeleteLocationByIdAsync(int id, CancellationToken cancellationToken) { return _locationDataService.DeleteByIdAsync(id, cancellationToken); } private static void EnsureLocationInCallerScope(ClaimsPrincipal user, int? locationAccountId) { switch (WorkOrderMediaAuthorization.ResolveMediaScope(user)) { case MediaAccountScope.OrgWide: return; case MediaAccountScope.Account caller: if (locationAccountId != caller.AccountId) throw new UnauthorizedAccessException(); return; default: throw new UnauthorizedAccessException(); } } private async Task EnsureAccountAssignableAsync( ClaimsPrincipal user, int? requestedAccountId, int? existingLocationAccountId, CancellationToken cancellationToken) { if (requestedAccountId is not int accountId) return; if (!await _accountDataService.ExistsActiveAsync(accountId, cancellationToken)) { throw new ValidationException(new[] { new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Account was not found.") }); } switch (WorkOrderMediaAuthorization.ResolveMediaScope(user)) { case MediaAccountScope.OrgWide: return; case MediaAccountScope.Account caller: if (accountId != caller.AccountId || (existingLocationAccountId is int current && current != caller.AccountId)) { throw new UnauthorizedAccessException(); } return; default: throw new UnauthorizedAccessException(); } } // Private helper methods private static string? GetActorId(ClaimsPrincipal user) => user.FindFirst(ClaimTypes.NameIdentifier)?.Value; private static void ThrowOnInvalidContacts(List contacts) { var failures = SiteContactsValidation.Validate(contacts); if (failures.Count > 0) throw new ValidationException(failures); } private static void ApplySiteContactsForCreate( Locations location, List contacts, string? actorId) { ThrowOnInvalidContacts(contacts); var now = DateTime.Now; var siteContacts = contacts .Select((row, index) => new Contacts { FirstName = row.Name!.Trim(), MiddleName = null, LastName = null, PhoneNumber = row.Phone!.Trim(), SiteContactOrder = index, AccountId = location.AccountId, CreatedDate = now, createdby = actorId }) .ToList(); location.Contacts = siteContacts; // The first site contact mirrors Location.PhoneNumber for legacy consumers. location.PhoneNumber = siteContacts[0].PhoneNumber; } private static void ApplySiteContactsForUpdate( Locations location, List contacts, string? actorId) { ThrowOnInvalidContacts(contacts); var existingById = (location.Contacts ?? Enumerable.Empty()) .ToDictionary(c => c.Id); var keptIds = new HashSet(); for (var i = 0; i < contacts.Count; i++) { var row = contacts[i]; var name = row.Name!.Trim(); var phone = row.Phone!.Trim(); if (row.Id is int id) { if (!existingById.TryGetValue(id, out var existing) || existing.LocationId != location.Id || existing.IsDeleted == true) { throw new ValidationException(new[] { new ValidationFailure( $"{nameof(LocationUpdateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Id)}", $"Contact {id} does not exist, is deleted, or does not belong to this location.") }); } keptIds.Add(id); existing.FirstName = name; existing.MiddleName = null; existing.LastName = null; existing.PhoneNumber = phone; existing.SiteContactOrder = i; existing.AccountId = location.AccountId; existing.LastModificationTime = DateTime.Now; } else { location.Contacts ??= new List(); location.Contacts.Add(new Contacts { FirstName = name, MiddleName = null, LastName = null, PhoneNumber = phone, SiteContactOrder = i, AccountId = location.AccountId, CreatedDate = DateTime.Now, createdby = actorId }); } } // Rows omitted from the request are soft deleted so historical // WorkOrderContacts keep rendering. var now = DateTime.Now; foreach (var existing in existingById.Values) { if (keptIds.Contains(existing.Id) || existing.IsDeleted == true) continue; existing.IsDeleted = true; existing.DeleterUserId = actorId; existing.DeletionTime = now; existing.LastModificationTime = now; } // The first site contact mirrors Location.PhoneNumber for legacy consumers. location.PhoneNumber = contacts[0].Phone!.Trim(); } private async Task>> GetSiteContactsByLocationIdsAsync( List items, CancellationToken cancellationToken) { if (items.Count == 0) return new Dictionary>(); var rows = await _locationDataService.GetSiteContactsByLocationIdsAsync( items.Select(l => l.Id).ToList(), cancellationToken); return rows .GroupBy(c => c.LocationId!.Value) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); } private static IReadOnlyList MapSiteContacts(IEnumerable? contacts) { if (contacts == null) return new List(); return contacts .OrderBy(c => c.SiteContactOrder) .ThenBy(c => c.Id) .Select(c => new SiteContactResponseDTO { Id = c.Id, Name = c.FirstName, Phone = c.PhoneNumber }) .ToList(); } private LocationDTO MapToDTO(Locations location, IReadOnlyDictionary>? contactsByLocation = null) { return new LocationDTO { Id = location.Id, Name = location.Name, Title = location.Title, Address1 = location.Address1, Address2 = location.Address2, City = location.City, State = location.State, Zip = location.Zip, PhoneNumber = location.PhoneNumber, Email = location.Email, Status = location.Status, AccountId = location.AccountId, AccountName = location.Account?.Name, CreatedDate = location.CreatedDate, CreatedBy = location.createdby, Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts) ? MapSiteContacts(contacts) : MapSiteContacts(location.Contacts?.Where(c => c.IsDeleted != true)) }; } } }