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> 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 GetAsync(int id, CancellationToken cancellationToken) { var row = await _templateData.GetDetailAsync(id, cancellationToken); return row == null ? null : Map(row); } public async Task 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 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()).ToList() }; var id = await _templateData.CreateAsync(entity, cancellationToken); return await GetSavedAsync(id, cancellationToken); } public async Task 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 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 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? 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? procedures = null; if (request.Procedures != null) { procedures = new List(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; } }