mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
257 lines
11 KiB
C#
257 lines
11 KiB
C#
using System.Security.Claims;
|
|
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.DataServices.Models;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class ServicesRegistryService : IServicesRegistryService
|
|
{
|
|
// Product contract: the registry only accepts the three work-order types
|
|
// the product currently supports. Any other WorkOrderType is rejected.
|
|
public static readonly IReadOnlySet<WorkOrderType> AllowedWorkOrderTypes =
|
|
new HashSet<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
|
|
|
|
private static readonly WorkOrderType[] DefaultSupportedWorkOrderTypes =
|
|
{ WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
|
|
|
|
public const string SchedulerRole = "Scheduler";
|
|
public const string AdminRole = "Admin";
|
|
|
|
private const int NameMaxLength = 200;
|
|
private const int IconKeyMaxLength = 64;
|
|
|
|
private readonly IServicesRegistryDataService _dataService;
|
|
|
|
public ServicesRegistryService(IServicesRegistryDataService dataService)
|
|
{
|
|
_dataService = dataService;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
|
|
{
|
|
var catalogType = WorkOrderCatalogType.For(workOrderType);
|
|
ValidateWorkOrderTypeFilter(catalogType);
|
|
var rows = await _dataService.GetServiceRowsAsync(isActive, catalogType, cancellationToken);
|
|
return rows.Select(MapToDto).ToList();
|
|
}
|
|
|
|
public async Task<ServiceDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
|
|
{
|
|
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
|
|
return row == null ? null : MapToDto(row);
|
|
}
|
|
|
|
public async Task<ServiceDto> CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken)
|
|
{
|
|
EnsureCanMutate(user);
|
|
|
|
var name = RequireValidName(input.Name);
|
|
var trade = RequireCanonicalTrade(input.Trade);
|
|
var iconKey = RequireValidIconKey(input.IconKey);
|
|
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
|
|
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
|
|
|
|
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), null, cancellationToken))
|
|
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
|
|
|
|
var service = new Service
|
|
{
|
|
Name = name,
|
|
NormalizedName = TradeCatalog.Normalize(name),
|
|
Trade = trade,
|
|
IconKey = iconKey,
|
|
RequiresCompletionDocument = input.RequiresCompletionDocument,
|
|
CompletionDocTemplateId = template?.Id,
|
|
IsActive = true,
|
|
SupportedWorkOrderTypes = supportedTypes
|
|
.Select(t => new ServiceWorkOrderType { WorkOrderType = t })
|
|
.ToList()
|
|
};
|
|
|
|
try
|
|
{
|
|
await _dataService.AddServiceAsync(service, cancellationToken);
|
|
}
|
|
catch (ServicesRegistryDuplicateNameException)
|
|
{
|
|
throw DuplicateName();
|
|
}
|
|
|
|
var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken);
|
|
return MapToDto(row!);
|
|
}
|
|
|
|
public async Task<ServiceDto> UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken)
|
|
{
|
|
EnsureCanMutate(user);
|
|
|
|
var service = await _dataService.GetServiceForUpdateAsync(id, cancellationToken);
|
|
if (service == null || service.IsDeleted == true)
|
|
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
|
|
|
|
var name = RequireValidName(input.Name);
|
|
var trade = RequireCanonicalTrade(input.Trade);
|
|
var iconKey = RequireValidIconKey(input.IconKey);
|
|
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
|
|
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
|
|
|
|
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), id, cancellationToken))
|
|
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
|
|
|
|
service.Name = name;
|
|
service.NormalizedName = TradeCatalog.Normalize(name);
|
|
service.Trade = trade;
|
|
service.IconKey = iconKey;
|
|
service.RequiresCompletionDocument = input.RequiresCompletionDocument;
|
|
service.CompletionDocTemplateId = template?.Id;
|
|
if (input.IsActive.HasValue)
|
|
{
|
|
EnsureCanChangeActiveState(user);
|
|
service.IsActive = input.IsActive.Value;
|
|
}
|
|
|
|
try
|
|
{
|
|
await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken);
|
|
}
|
|
catch (ServicesRegistryDuplicateNameException)
|
|
{
|
|
throw DuplicateName();
|
|
}
|
|
|
|
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
|
|
return MapToDto(row!);
|
|
}
|
|
|
|
public async Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
|
|
{
|
|
if (user is null || !user.IsInRole(AdminRole))
|
|
throw new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.");
|
|
|
|
var deactivated = await _dataService.DeactivateServiceAsync(id, cancellationToken);
|
|
if (!deactivated)
|
|
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
|
|
}
|
|
|
|
private static void EnsureCanMutate(ClaimsPrincipal user)
|
|
{
|
|
if (user is null || !(user.IsInRole(SchedulerRole) || user.IsInRole(AdminRole)))
|
|
throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.");
|
|
}
|
|
|
|
private static void EnsureCanChangeActiveState(ClaimsPrincipal user)
|
|
{
|
|
if (user is null || !user.IsInRole(AdminRole))
|
|
throw new ServicesRegistryValidationException("Forbidden", "Only administrators can change service active state.");
|
|
}
|
|
|
|
private static ServicesRegistryValidationException DuplicateName() =>
|
|
new("DuplicateName", "A service with this name already exists.");
|
|
|
|
private static string RequireValidName(string? name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
throw new ServicesRegistryValidationException("NameRequired", "Service name is required.");
|
|
|
|
var trimmed = name.Trim();
|
|
if (trimmed.Length > NameMaxLength)
|
|
throw new ServicesRegistryValidationException("NameInvalid", $"Service name cannot exceed {NameMaxLength} characters.");
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
private static string RequireCanonicalTrade(string? trade)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(trade))
|
|
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
|
|
|
|
var normalized = TradeCatalog.Normalize(trade);
|
|
var canonical = TradeCatalog.CanonicalTrades
|
|
.FirstOrDefault(t => TradeCatalog.Normalize(t) == normalized);
|
|
|
|
if (canonical == null)
|
|
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
|
|
|
|
return canonical;
|
|
}
|
|
|
|
private static string RequireValidIconKey(string? iconKey)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(iconKey))
|
|
throw new ServicesRegistryValidationException("IconKeyInvalid", "Icon key is required.");
|
|
|
|
var trimmed = iconKey.Trim();
|
|
if (trimmed.Length > IconKeyMaxLength)
|
|
throw new ServicesRegistryValidationException("IconKeyInvalid", $"Icon key cannot exceed {IconKeyMaxLength} characters.");
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
private static List<WorkOrderType> RequireValidSupportedTypes(IEnumerable<WorkOrderType>? supportedTypes)
|
|
{
|
|
// The registry form does not expose type categories yet. Until it
|
|
// does, an omitted or empty category list keeps a service available
|
|
// to the three supported work-order types.
|
|
var types = supportedTypes?.Distinct().ToList();
|
|
if (types == null || types.Count == 0)
|
|
return DefaultSupportedWorkOrderTypes.ToList();
|
|
|
|
if (types.Any(t => !AllowedWorkOrderTypes.Contains(t)))
|
|
throw new ServicesRegistryValidationException(
|
|
"WorkOrderTypeInvalid",
|
|
"Supported work order types may only include PM, Reactive, or Emergency.");
|
|
|
|
return types;
|
|
}
|
|
|
|
private static void ValidateWorkOrderTypeFilter(WorkOrderType? workOrderType)
|
|
{
|
|
if (workOrderType.HasValue && !AllowedWorkOrderTypes.Contains(workOrderType.Value))
|
|
throw new ServicesRegistryValidationException(
|
|
"WorkOrderTypeInvalid",
|
|
"Supported work order types may only include PM, Reactive, or Emergency.");
|
|
}
|
|
|
|
private async Task<LinkedTemplateSummary?> ResolveTemplateLinkAsync(ServiceInput input, CancellationToken cancellationToken)
|
|
{
|
|
// Toggle off clears/ignores any template link.
|
|
if (!input.RequiresCompletionDocument || input.CompletionDocTemplateId is not int templateId)
|
|
return null;
|
|
|
|
var template = await _dataService.GetActiveTemplateSummaryAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
throw new ServicesRegistryValidationException(
|
|
"TemplateInvalid",
|
|
"Completion document template must reference an active, non-deleted template.");
|
|
|
|
return template;
|
|
}
|
|
|
|
private static ServiceDto MapToDto(ServiceRegistryRow row)
|
|
{
|
|
return new ServiceDto
|
|
{
|
|
Id = row.Id,
|
|
Name = row.Name,
|
|
Trade = row.Trade,
|
|
IconKey = row.IconKey,
|
|
RequiresCompletionDocument = row.RequiresCompletionDocument,
|
|
CompletionDocTemplate = row.CompletionDocTemplateId == null
|
|
? null
|
|
: new LinkedTemplateSummaryDto
|
|
{
|
|
Id = row.CompletionDocTemplateId.Value,
|
|
Name = row.CompletionDocTemplateName ?? ""
|
|
},
|
|
IsActive = row.IsActive,
|
|
SupportedWorkOrderTypes = row.SupportedWorkOrderTypes.OrderBy(t => (int)t).ToList()
|
|
};
|
|
}
|
|
}
|
|
}
|