shoc-backend/SeaHaven.Services/Implementation/LocationService.cs
2026-09-15 19:03:54 -03:00

514 lines
20 KiB
C#

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
{
/// <summary>
/// Location service implementation
/// Contains business logic, validation, and orchestration for Location operations
/// </summary>
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<LocationDTO?> GetLocationByIdAsync(int id)
{
var location = await _locationDataService.GetByIdAsync(id);
return location == null ? null : MapToDTO(location);
}
public async Task<LocationDTO?> GetLocationByIdWithDetailsAsync(int id)
{
var location = await _locationDataService.GetByIdWithDetailsAsync(id);
return location == null ? null : MapToDTO(location);
}
public async Task<IEnumerable<LocationDTO>> GetAllLocationsAsync()
{
var locations = await _locationDataService.GetAllAsync();
return locations.Select(l => MapToDTO(l));
}
public async Task<IEnumerable<LocationDTO>> GetLocationsByAccountIdAsync(int accountId)
{
var locations = await _locationDataService.GetByAccountIdAsync(accountId);
return locations.Select(l => MapToDTO(l));
}
public async Task<PagedResult<LocationDTO>> GetLocationsPagedAsync(int page, int pageSize, string? search = null)
{
var (items, totalCount) = await _locationDataService.GetPagedAsync(page, pageSize, search);
return new PagedResult<LocationDTO>
{
Items = items.Select(l => MapToDTO(l)).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
public async Task<IReadOnlyList<SiteOptionDTO>> 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<LocationDTO> 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<LocationDTO> 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<bool> LocationExistsAsync(int id)
{
return await _locationDataService.ExistsAsync(id);
}
public async Task<int> GetTotalLocationCountAsync()
{
return await _locationDataService.CountAsync();
}
public async Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(
int page,
int pageSize,
string? search,
string? states = null,
CancellationToken cancellationToken = default)
{
var stateFilter = NormalizeStateFilter(states);
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
// SH-138: one bounded contact read for the whole page, never per row.
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
return new PagedResult<LocationDTO>
{
Items = items.Select(l => MapToDTO(l, contactsByLocation)).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
internal static IReadOnlyCollection<string>? 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<LocationDTO?> 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<SiteContactRequestDTO> 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<SiteContactRequestDTO> contacts)
{
ApplySiteContactsForUpdate(location, contacts, GetActorId(user));
}
await _locationDataService.UpdateAsync(location, cancellationToken);
}
public Task<bool> 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<SiteContactRequestDTO> contacts)
{
var failures = SiteContactsValidation.Validate(contacts);
if (failures.Count > 0)
throw new ValidationException(failures);
}
private static void ApplySiteContactsForCreate(
Locations location,
List<SiteContactRequestDTO> 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<SiteContactRequestDTO> contacts,
string? actorId)
{
ThrowOnInvalidContacts(contacts);
var existingById = (location.Contacts ?? Enumerable.Empty<Contacts>())
.ToDictionary(c => c.Id);
var keptIds = new HashSet<int>();
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<Contacts>();
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<IReadOnlyDictionary<int, IReadOnlyList<Contacts>>> GetSiteContactsByLocationIdsAsync(
List<Locations> items,
CancellationToken cancellationToken)
{
if (items.Count == 0)
return new Dictionary<int, IReadOnlyList<Contacts>>();
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<Contacts>)g.ToList());
}
private static IReadOnlyList<SiteContactResponseDTO> MapSiteContacts(IEnumerable<Contacts>? contacts)
{
if (contacts == null)
return new List<SiteContactResponseDTO>();
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<int, IReadOnlyList<Contacts>>? 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,
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))
};
}
}
}