mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
730 lines
29 KiB
C#
730 lines
29 KiB
C#
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
|
|
{
|
|
/// <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;
|
|
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
|
|
private readonly ITeamPermissionPolicy _permissionPolicy;
|
|
|
|
/// <summary>Upper bound on ids returned for the open work orders link.</summary>
|
|
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<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,
|
|
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<LocationDTO>
|
|
{
|
|
Items = items.Select(l => MapToDTO(l, contactsByLocation)).ToList(),
|
|
TotalCount = totalCount,
|
|
Page = page,
|
|
PageSize = pageSize
|
|
};
|
|
}
|
|
|
|
internal static readonly IReadOnlyCollection<string> 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<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);
|
|
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<SiteContactRequestDTO> 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<SiteContactRequestDTO> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<bool> 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<SiteOpenWorkOrdersDTO?> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>A new site needs a client, a full address and at least one point of contact.</summary>
|
|
private static void ThrowOnMissingSiteFields(LocationCreateRequestDTO request)
|
|
{
|
|
var failures = new List<ValidationFailure>();
|
|
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<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.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<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.UtcNow;
|
|
}
|
|
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.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<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,
|
|
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))
|
|
};
|
|
}
|
|
}
|
|
}
|