shoc-backend/SeaHaven.Services/Implementation/ServicesRegistryService.cs

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()
};
}
}
}