shoc-backend/SeaHaven.Services/Implementation/CompletionDocTemplateService.cs
Alexandre Brandizzi fc3a29f399 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
2026-09-25 16:40:36 -03:00

264 lines
11 KiB
C#

using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public sealed class CompletionDocTemplateService : ICompletionDocTemplateService
{
private readonly ICompletionDocTemplateDataService _templateData;
private readonly ITeamPermissionOverrideDataService _permissionUsers;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly IWorkOrderAccountResolver _accountResolver;
public CompletionDocTemplateService(
ICompletionDocTemplateDataService templateData,
ITeamPermissionOverrideDataService permissionUsers,
ITeamPermissionPolicy permissionPolicy,
IWorkOrderAccountResolver accountResolver)
{
_templateData = templateData;
_permissionUsers = permissionUsers;
_permissionPolicy = permissionPolicy;
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<CompletionDocTemplateDetailDto>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var rows = await _templateData.ListAsync(
search,
serviceKey,
WorkOrderCatalogType.For(workOrderType),
cancellationToken);
return rows.Select(Map).ToList();
}
public async Task<CompletionDocTemplateDetailDto?> GetAsync(int id, CancellationToken cancellationToken)
{
var row = await _templateData.GetDetailAsync(id, cancellationToken);
return row == null ? null : Map(row);
}
public async Task<CompletionDocTemplateLinkedWorkOrdersDto> GetLinkedWorkOrdersAsync(
ClaimsPrincipal user,
int id,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
if (await _templateData.GetDetailAsync(id, cancellationToken) == null)
throw NotFound();
var ids = await _templateData.GetOpenLinkedWorkOrderIdsAsync(id, accountId, cancellationToken);
return new CompletionDocTemplateLinkedWorkOrdersDto { Count = ids.Count, WorkOrderIds = ids.ToList() };
}
public async Task<CompletionDocTemplateDetailDto> CreateAsync(
ClaimsPrincipal user,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
var actorId = await EnsureAllowedAsync(
user,
TeamPermissionKeys.CreateCompletionDocTemplates,
"You do not have permission to create completion document templates.",
cancellationToken);
var input = Validate(request);
var entity = new CompletionDocTemplate
{
Name = input.Name,
ExtraSafetyNote = input.ExtraSafetyNote,
ServiceKey = input.ServiceKey ?? "",
WorkOrderType = request.WorkOrderType,
TemplateUrl = input.TemplateUrl ?? "",
IsActive = request.IsActive ?? true,
createdby = actorId,
Procedures = (input.Procedures ?? new List<CompletionDocTemplateProcedure>()).ToList()
};
var id = await _templateData.CreateAsync(entity, cancellationToken);
return await GetSavedAsync(id, cancellationToken);
}
public async Task<CompletionDocTemplateDetailDto> UpdateAsync(
ClaimsPrincipal user,
int id,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
await EnsureAllowedAsync(
user,
TeamPermissionKeys.EditCompletionDocTemplates,
"You do not have permission to edit completion document templates.",
cancellationToken);
var input = Validate(request);
var entity = await _templateData.GetTrackedForEditAsync(id, cancellationToken) ?? throw NotFound();
entity.Name = input.Name;
entity.ExtraSafetyNote = input.ExtraSafetyNote;
if (input.ServiceKey != null)
entity.ServiceKey = input.ServiceKey;
if (request.WorkOrderTypeSpecified)
entity.WorkOrderType = request.WorkOrderType;
if (input.TemplateUrl != null)
entity.TemplateUrl = input.TemplateUrl;
if (request.IsActive.HasValue)
entity.IsActive = request.IsActive.Value;
await _templateData.SaveEditAsync(entity, input.Procedures, cancellationToken);
return await GetSavedAsync(id, cancellationToken);
}
public async Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
{
var actorId = await EnsureAllowedAsync(
user,
TeamPermissionKeys.DeleteCompletionDocTemplates,
"You do not have permission to delete completion document templates.",
cancellationToken);
if (!await _templateData.DeleteAndUnlinkServicesAsync(id, actorId, cancellationToken))
throw NotFound();
}
private async Task<string> EnsureAllowedAsync(
ClaimsPrincipal user,
string permissionKey,
string deniedMessage,
CancellationToken cancellationToken)
{
var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionUsers.GetUserAsync(userId, cancellationToken);
if (permissionUser is null
|| !_permissionPolicy.IsAllowed(permissionUser.RoleName, permissionKey, permissionUser.Overrides))
{
throw new WorkOrderBoardValidationException("Forbidden", deniedMessage);
}
return userId!;
}
private async Task<CompletionDocTemplateDetailDto> GetSavedAsync(int id, CancellationToken cancellationToken)
{
var row = await _templateData.GetDetailAsync(id, cancellationToken) ?? throw NotFound();
return Map(row);
}
private sealed record ValidatedInput(
string Name,
string? ExtraSafetyNote,
string? ServiceKey,
string? TemplateUrl,
IReadOnlyList<CompletionDocTemplateProcedure>? Procedures);
private static ValidatedInput Validate(CompletionDocTemplateCreateDto request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Name))
throw new WorkOrderBoardValidationException("TitleRequired", "Document Title is required.");
var extraSafetyNote = string.IsNullOrWhiteSpace(request.ExtraSafetyNote)
? null
: request.ExtraSafetyNote.Trim();
if (extraSafetyNote?.Length > CompletionDocTemplate.ExtraSafetyNoteMaxLength)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"Extra safety note must be {CompletionDocTemplate.ExtraSafetyNoteMaxLength} characters or fewer.");
}
var templateUrl = request.TemplateUrl?.Trim();
if (!string.IsNullOrEmpty(templateUrl)
&& (!Uri.TryCreate(templateUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)))
{
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
}
// Blank procedure names and descriptions are allowed: authors may keep a
// placeholder step and fill it in later.
List<CompletionDocTemplateProcedure>? procedures = null;
if (request.Procedures != null)
{
procedures = new List<CompletionDocTemplateProcedure>(request.Procedures.Count);
foreach (var procedure in request.Procedures)
{
var name = procedure?.Name?.Trim() ?? "";
if (name.Length > CompletionDocTemplateProcedure.NameMaxLength)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"Procedure name must be {CompletionDocTemplateProcedure.NameMaxLength} characters or fewer.");
}
procedures.Add(new CompletionDocTemplateProcedure
{
SortOrder = procedures.Count,
Name = name,
Description = procedure?.Description ?? ""
});
}
}
return new ValidatedInput(
request.Name.Trim(),
extraSafetyNote,
request.ServiceKey?.Trim(),
templateUrl,
procedures);
}
private static WorkOrderBoardValidationException NotFound()
=> new("NotFound", "Template not found.");
private static CompletionDocTemplateDetailDto Map(CompletionDocTemplateDetailRow row)
{
var createdByName = string.Join(
" ",
new[] { row.CreatedByFirstName, row.CreatedByLastName }
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(part => part!.Trim()));
return new CompletionDocTemplateDetailDto
{
Id = row.Id,
Name = row.Name,
ServiceKey = row.ServiceKey,
WorkOrderType = row.WorkOrderType,
TemplateUrl = row.TemplateUrl,
IsActive = row.IsActive,
ExtraSafetyNote = row.ExtraSafetyNote,
Procedures = row.Procedures
.Select(p => new CompletionDocTemplateProcedureDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
SortOrder = p.SortOrder
})
.ToList(),
CreatedAt = AsUtc(row.CreatedAt),
UpdatedAt = AsUtc(row.UpdatedAt),
CreatedByName = createdByName.Length == 0 ? null : createdByName
};
}
// Audit timestamps are written as DateTime.UtcNow; the store drops the kind.
private static DateTime? AsUtc(DateTime? value)
=> value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null;
}
}