using System.Security.Claims; using Data.SeaHavenIndustries; using FluentValidation; using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; 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; private readonly ITeamPermissionOverrideDataService _permissionOverrideData; private readonly ITeamPermissionPolicy _permissionPolicy; /// Upper bound on ids returned for the open work orders link. public const int MaxOpenWorkOrderIds = 200; public LocationService( ILocationDataService locationDataService, IAccountDataService accountDataService, ICreateLocationValidation createValidator, IUpdateLocationValidation updateValidator, ITeamPermissionOverrideDataService permissionOverrideData, ITeamPermissionPolicy permissionPolicy) { _locationDataService = locationDataService; _accountDataService = accountDataService; _createValidator = createValidator; _updateValidator = updateValidator; _permissionOverrideData = permissionOverrideData; _permissionPolicy = permissionPolicy; } // 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); ThrowOnMissingSiteFields(request); var siteCode = request.Name?.Trim(); if (string.IsNullOrEmpty(siteCode)) { throw new ValidationException(new[] { new ValidationFailure(nameof(LocationCreateRequestDTO.Name), "Site Code is required.") }); } await EnsureSiteCodeAvailableAsync(siteCode, request.AccountId, excludeLocationId: null, cancellationToken); var location = new Locations { Name = siteCode, 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, Notes = NormalizeNotes(request.Notes) }; ApplySiteContactsForCreate(location, request.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.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 ?? location.Status; if (request.Notes != null) location.Notes = NormalizeNotes(request.Notes); var previousAccountId = location.AccountId; if (request.AccountId is int accountId) { await EnsureAccountAssignableAsync(user, accountId, location.AccountId, cancellationToken); location.AccountId = accountId; } await ApplySiteCodeForUpdateAsync(location, request.Name, previousAccountId, cancellationToken); // 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 async Task UpdateSiteContactInfoAsync( int id, SiteContactInfoRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken) { if (request.Contacts is not List contacts) { throw new ValidationException(new[] { new ValidationFailure(nameof(SiteContactInfoRequestDTO.Contacts), "At least one contact is required.") }); } var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken); if (location == null) throw new KeyNotFoundException($"Location with ID {id} not found"); EnsureLocationInCallerScope(user, location.AccountId); ApplySiteContactsForUpdate(location, contacts, GetActorId(user)); location.Notes = NormalizeNotes(request.Notes); await _locationDataService.UpdateAsync(location, cancellationToken); } /// /// Deletes a site for good from the caller's point of view. The row is kept as /// a tombstone because work orders, assets and history reference it through /// restrict foreign keys; those references are left exactly as they were. /// public async Task DeleteLocationByIdAsync( int id, ClaimsPrincipal user, CancellationToken cancellationToken) { await EnsureCanDeleteSitesAsync(user, cancellationToken); var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken); if (location == null) return false; EnsureLocationInCallerScope(user, location.AccountId); location.IsDeleted = true; location.DeletionTime = DateTime.UtcNow; location.DeleterUserId = GetActorId(user); await _locationDataService.UpdateAsync(location, cancellationToken); return true; } public async Task GetOpenWorkOrdersAsync( int id, ClaimsPrincipal user, CancellationToken cancellationToken) { var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken); if (location == null) return null; EnsureLocationInCallerScope(user, location.AccountId); int? callerAccountId = WorkOrderMediaAuthorization.ResolveMediaScope(user) is MediaAccountScope.Account caller ? caller.AccountId : null; var (count, ids) = await _locationDataService.GetOpenWorkOrderIdsAsync( location.Id, location.Name, location.AccountId, callerAccountId, MaxOpenWorkOrderIds, cancellationToken); return new SiteOpenWorkOrdersDTO { Count = count, WorkOrderIds = ids }; } private async Task EnsureCanDeleteSitesAsync(ClaimsPrincipal user, CancellationToken cancellationToken) { var userId = GetActorId(user); var permissionUser = string.IsNullOrWhiteSpace(userId) ? null : await _permissionOverrideData.GetUserAsync(userId, cancellationToken); if (permissionUser is null || !_permissionPolicy.IsAllowed( permissionUser.RoleName, TeamPermissionKeys.DeleteSites, permissionUser.Overrides)) { throw new SiteForbiddenException(SiteForbiddenException.DeleteDeniedMessage); } } private async Task EnsureSiteCodeAvailableAsync( string siteCode, int? accountId, int? excludeLocationId, CancellationToken cancellationToken) { if (await _locationDataService.SiteCodeExistsAsync(siteCode, accountId, excludeLocationId, cancellationToken)) throw new SiteCodeConflictException(); } /// /// The site code is immutable once set. A blank legacy code may be filled in /// once; the code must stay unique within the site's account, including when /// the site moves to another account. /// private async Task ApplySiteCodeForUpdateAsync( Locations location, string? requestedCode, int? previousAccountId, CancellationToken cancellationToken) { var stored = location.Name?.Trim(); var requested = requestedCode?.Trim(); if (!string.IsNullOrEmpty(stored) && !string.IsNullOrEmpty(requested) && !string.Equals(stored, requested, StringComparison.OrdinalIgnoreCase)) { throw new ValidationException(new[] { new ValidationFailure(nameof(LocationUpdateRequestDTO.Name), "Site Code cannot be changed.") }); } var fillsBlankCode = string.IsNullOrEmpty(stored) && !string.IsNullOrEmpty(requested); var code = fillsBlankCode ? requested : stored; if (!string.IsNullOrEmpty(code) && (fillsBlankCode || previousAccountId != location.AccountId)) await EnsureSiteCodeAvailableAsync(code, location.AccountId, location.Id, cancellationToken); if (fillsBlankCode) location.Name = requested; } private static string? NormalizeNotes(string? notes) => string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); 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; /// A new site needs a client, a full address and at least one point of contact. private static void ThrowOnMissingSiteFields(LocationCreateRequestDTO request) { var failures = new List(); if (request.AccountId == null) failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Client is required.")); if (string.IsNullOrWhiteSpace(request.Address)) failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Address), "Street Address is required.")); if (string.IsNullOrWhiteSpace(request.City)) failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.City), "City is required.")); if (string.IsNullOrWhiteSpace(request.State)) failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.State), "State is required.")); if (request.Contacts == null || request.Contacts.Count == 0) failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Contacts), "At least one contact is required.")); if (failures.Count > 0) throw new ValidationException(failures); } 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.UtcNow; 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; } 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.UtcNow; } 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.UtcNow, createdby = actorId }); } } // Rows omitted from the request are soft deleted so historical // WorkOrderContacts keep rendering. var now = DateTime.UtcNow; 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; } } 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, Notes = location.Notes, 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)) }; } } }