From af84001bf1afeb1ec9f40fb839f4d1a691225978 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 13 Jul 2026 13:13:00 -0300 Subject: [PATCH] feat(work-orders): add Phase 6 slide-over detail comments audit and media API --- .../Controllers/WorkOrderController.cs | 224 +++++++++++++++++- .../CompletionDocTemplateDataService.cs | 103 ++++++++ .../WorkOrderDetailDataService.cs | 90 +++++++ .../Interfaces/IWorkOrderDetailDataService.cs | 27 +++ .../Interfaces/WorkOrderDetailModels.cs | 30 +++ SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 104 ++++++++ .../Helpers/WorkOrderAuditProjection.cs | 41 ++++ .../Helpers/WorkOrderCommentProjection.cs | 23 ++ .../Helpers/WorkOrderMediaProjection.cs | 91 +++++++ .../Implementation/WorkOrderAuditService.cs | 12 +- .../Implementation/WorkOrderCommentService.cs | 59 +++++ .../WorkOrderCompletionService.cs | 178 ++++++++++++++ .../Implementation/WorkOrderDetailService.cs | 149 ++++++++++++ .../Implementation/WorkOrderMediaService.cs | 134 +++++++++++ .../Interfaces/IWorkOrderCommentService.cs | 11 + .../Interfaces/IWorkOrderCompletionService.cs | 19 ++ .../Interfaces/IWorkOrderDetailService.cs | 10 + .../Interfaces/IWorkOrderMediaService.cs | 12 + 18 files changed, 1313 insertions(+), 4 deletions(-) create mode 100644 SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderCommentService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderCompletionService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderDetailService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderMediaService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 685c3c6..9bfdfdf 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -48,6 +48,10 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderBoardCreateService _boardCreateService; private readonly IWorkOrderBoardCancelService _boardCancelService; private readonly IWorkOrderAdvancedSearchService _advancedSearchService; + private readonly IWorkOrderDetailService _workOrderDetailService; + private readonly IWorkOrderCommentService _workOrderCommentService; + private readonly IWorkOrderCompletionService _workOrderCompletionService; + private readonly IWorkOrderMediaService _workOrderMediaService; public WorkOrderController( UserManager userManager, @@ -69,7 +73,11 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderBoardUpdateService boardUpdateService, IWorkOrderBoardCreateService boardCreateService, IWorkOrderBoardCancelService boardCancelService, - IWorkOrderAdvancedSearchService advancedSearchService) + IWorkOrderAdvancedSearchService advancedSearchService, + IWorkOrderDetailService workOrderDetailService, + IWorkOrderCommentService workOrderCommentService, + IWorkOrderCompletionService workOrderCompletionService, + IWorkOrderMediaService workOrderMediaService) { _userManager = userManager; _workOrderService = workOrderService; @@ -91,6 +99,10 @@ namespace Api.SeaHavenIndustries.Controllers _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; _advancedSearchService = advancedSearchService; + _workOrderDetailService = workOrderDetailService; + _workOrderCommentService = workOrderCommentService; + _workOrderCompletionService = workOrderCompletionService; + _workOrderMediaService = workOrderMediaService; } [HttpGet("board")] @@ -220,6 +232,216 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("{id:int}/detail")] + public async Task GetDetail(int id) + { + var detail = await _workOrderDetailService.GetDetailAsync(id); + if (detail == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(detail); + } + + [HttpGet("{id:int}/audit")] + public async Task GetAudit(int id, [FromQuery] int limit = 50) + { + var audit = await _workOrderDetailService.GetAuditAsync(id, limit); + if (audit == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(audit); + } + + [HttpGet("{id:int}/comments")] + public async Task GetBoardComments(int id) + { + var comments = await _workOrderCommentService.GetCommentsAsync(id); + if (comments == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(comments); + } + + [HttpPost("{id:int}/comments")] + public async Task AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId); + return Ok(comment); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("completion-templates")] + public async Task GetCompletionTemplates( + [FromQuery] string? serviceKey = null, + [FromQuery] WorkOrderType? workOrderType = null) + { + var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType); + return Ok(templates); + } + + [HttpGet("completion-templates/{id:int}")] + public async Task GetCompletionTemplate(int id) + { + var template = await _workOrderCompletionService.GetTemplateByIdAsync(id); + if (template == null) + return NotFound(new Response { Status = "Error", Message = "Template not found." }); + return Ok(template); + } + + [Authorize(Roles = "Admin")] + [HttpPost("completion-templates")] + public async Task CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var created = await _workOrderCompletionService.CreateTemplateAsync(request); + return Ok(created); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpPut("completion-templates/{id:int}")] + public async Task UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request); + return Ok(updated); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpDelete("completion-templates/{id:int}")] + public async Task DeleteCompletionTemplate(int id) + { + try + { + await _workOrderCompletionService.DeleteTemplateAsync(id); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id:int}/completion-doc")] + [RequestSizeLimit(30_000_000)] + public async Task UploadCompletionDoc( + int id, + [FromForm] IFormFile file, + [FromForm] string? signOffName = null, + [FromForm] string? signOffSignature = null, + [FromForm] string? workOrderVersion = null) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await SaveFileAsync(file); + var result = await _workOrderCompletionService.UploadCompletionDocAsync( + id, + new WorkOrderCompletionDocUploadDto + { + SignOffName = signOffName, + SignOffSignature = signOffSignature, + WorkOrderVersion = workOrderVersion + }, + fileUrl, + actorId); + return Ok(result); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("{id:int}/media")] + public async Task GetMedia(int id) + { + var media = await _workOrderMediaService.GetMediaAsync(id); + if (media == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(media); + } + + [HttpPost("{id:int}/media")] + [RequestSizeLimit(30_000_000)] + public async Task AddMedia( + int id, + [FromForm] WorkOrderMediaCategory category, + [FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await SaveFileAsync(file); + var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); + return Ok(media); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpDelete("{id:int}/media/{mediaId:int}")] + public async Task DeleteMedia(int id, int mediaId) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + [HttpPost] [Route("AddWorkorder")] public async Task Addworkorder([FromForm] Workorder_DTO model) diff --git a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs new file mode 100644 index 0000000..356b623 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs @@ -0,0 +1,103 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService + { + private readonly ApplicationDbContext _context; + + public CompletionDocTemplateDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType) + { + var query = _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true); + + if (!string.IsNullOrWhiteSpace(serviceKey)) + query = query.Where(t => t.ServiceKey == serviceKey); + + if (workOrderType.HasValue) + query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType); + + return await query + .OrderBy(t => t.Name) + .Select(MapRow) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.Id == id && t.IsDeleted != true) + .Select(MapRow) + .FirstOrDefaultAsync(); + } + + public async Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType) + { + if (!string.IsNullOrWhiteSpace(trade)) + { + var byTrade = await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade) + .OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1) + .ThenBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(MapRow) + .FirstOrDefaultAsync(); + + if (byTrade != null) + return byTrade; + } + + if (!workOrderType.HasValue) + return null; + + return await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType) + .OrderBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(MapRow) + .FirstOrDefaultAsync(); + } + + public async Task CreateAsync(CompletionDocTemplate template) + { + template.CreatedDate = DateTime.UtcNow; + await _context.CompletionDocTemplates.AddAsync(template); + await _context.SaveChangesAsync(); + return template; + } + + public async Task UpdateAsync(CompletionDocTemplate template) + { + template.LastModificationTime = DateTime.UtcNow; + _context.CompletionDocTemplates.Update(template); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var entity = await _context.CompletionDocTemplates.FindAsync(id); + if (entity == null) + return false; + + entity.IsDeleted = true; + entity.DeletionTime = DateTime.UtcNow; + await _context.SaveChangesAsync(); + return true; + } + + private static readonly System.Linq.Expressions.Expression> MapRow = + t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs new file mode 100644 index 0000000..8ea9358 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -0,0 +1,90 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDetailDataService : IWorkOrderDetailDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderDetailDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task ExistsAsync(int workOrderId) + => WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .AnyAsync(w => w.Id == workOrderId); + + public async Task GetExtendedFieldsAsync(int workOrderId) + { + return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .Where(w => w.Id == workOrderId) + .Select(w => new WorkOrderDetailExtendedFields( + w.Description, + w.Trade, + w.Problem, + w.SubTrade, + w.OriginalWeek, + w.OriginalDate, + w.CompletedDate, + w.SignOffName, + w.SignOffAttachment, + w.SignOffSignature)) + .FirstOrDefaultAsync(); + } + + public async Task> GetCommentsAsync(int workOrderId) + { + return await _context.Comments + .AsNoTracking() + .Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true) + .OrderByDescending(c => c.CreatedDate) + .ToListAsync(); + } + + public async Task> GetAuditLogsAsync(int workOrderId, int? limit = null) + { + var query = _context.WorkOrderAuditLogs + .AsNoTracking() + .Where(a => a.WorkOrderId == workOrderId) + .OrderByDescending(a => a.CreatedAt); + + if (limit.HasValue) + return await query.Take(limit.Value).ToListAsync(); + + return await query.ToListAsync(); + } + + public async Task> GetAttachmentsAsync(int workOrderId) + { + return await _context.workOrderAttachments + .AsNoTracking() + .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true) + .OrderByDescending(a => a.CreatedDate) + .ToListAsync(); + } + + public async Task> GetDispatchSignoffsAsync(int workOrderId) + { + return await _context.DispatchSignoffs + .AsNoTracking() + .Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true) + .OrderByDescending(s => s.SignedAt) + .Select(s => new DispatchSignoffRow( + s.DispatchId, + s.SignoffType, + s.Name, + s.SignedAt)) + .ToListAsync(); + } + + public async Task GetWorkOrderForMediaAsync(int workOrderId) + { + return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .FirstOrDefaultAsync(w => w.Id == workOrderId); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs new file mode 100644 index 0000000..3f61a70 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderDetailDataService + { + Task ExistsAsync(int workOrderId); + Task GetExtendedFieldsAsync(int workOrderId); + Task> GetCommentsAsync(int workOrderId); + Task> GetAuditLogsAsync(int workOrderId, int? limit = null); + Task> GetAttachmentsAsync(int workOrderId); + Task> GetDispatchSignoffsAsync(int workOrderId); + Task GetWorkOrderForMediaAsync(int workOrderId); + } + + public interface ICompletionDocTemplateDataService + { + Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType); + Task GetByIdAsync(int id); + Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType); + Task CreateAsync(CompletionDocTemplate template); + Task UpdateAsync(CompletionDocTemplate template); + Task DeleteAsync(int id); + } +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs new file mode 100644 index 0000000..cab956b --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderDetailExtendedFields( + string? Description, + string? Trade, + string? Problem, + string? SubTrade, + DateOnly? OriginalWeek, + DateOnly? OriginalDate, + DateTime? CompletedDate, + string? SignOffName, + string? SignOffAttachment, + string? SignOffSignature); + + public record DispatchSignoffRow( + int DispatchId, + string? SignoffType, + string? Name, + DateTime? SignedAt); + + public record CompletionDocTemplateRow( + int Id, + string Name, + string ServiceKey, + WorkOrderType? WorkOrderType, + string TemplateUrl, + bool IsActive); +} diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs new file mode 100644 index 0000000..7ac1437 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -0,0 +1,104 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class WorkOrderDetailDto + { + public WorkOrderDetailInfoDto Info { get; set; } = new(); + public WorkOrderCompletionDto Completion { get; set; } = new(); + public List Comments { get; set; } = new(); + public List Audit { get; set; } = new(); + public List Media { get; set; } = new(); + } + + public class WorkOrderDetailInfoDto : WorkOrderBoardRowDto + { + public string? Description { get; set; } + public string? Trade { get; set; } + public string? Problem { get; set; } + public string? SubTrade { get; set; } + public DateOnly? OriginalWeek { get; set; } + public DateOnly? OriginalDate { get; set; } + public DateTime? CompletedDate { get; set; } + } + + public class CompletionDocTemplateDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } + } + + public class CompletionDocTemplateCreateDto + { + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } = true; + } + + public class WorkOrderCompletionDto + { + public DocStatus? DocStatus { get; set; } + public CompletionDocTemplateDto? Template { get; set; } + public string? SignOffName { get; set; } + public string? SignOffAttachment { get; set; } + public string? SignOffSignature { get; set; } + public List DispatchSignoffs { get; set; } = new(); + } + + public class DispatchSignoffDto + { + public int DispatchId { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignedAt { get; set; } + } + + public class WorkOrderCommentDto + { + public int Id { get; set; } + public string? AuthorId { get; set; } + public string Text { get; set; } = ""; + public string Time { get; set; } = ""; + public string? Documents { get; set; } + } + + public class WorkOrderCommentCreateDto + { + public string Text { get; set; } = ""; + } + + public class WorkOrderAuditEntryDto + { + public string Type { get; set; } = "manual"; + public string? DispatcherId { get; set; } + public string Action { get; set; } = ""; + public string? FieldName { get; set; } + public string OldValue { get; set; } = ""; + public string NewValue { get; set; } = ""; + public string Time { get; set; } = ""; + public int? DispatchId { get; set; } + } + + public class WorkOrderMediaFileDto + { + public int Id { get; set; } + public WorkOrderMediaCategory Category { get; set; } + public string Url { get; set; } = ""; + public string? FileName { get; set; } + public string? UploadedAt { get; set; } + public bool IsLegacy { get; set; } + } + + public class WorkOrderCompletionDocUploadDto + { + public string? SignOffName { get; set; } + public string? SignOffSignature { get; set; } + public string? WorkOrderVersion { get; set; } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs new file mode 100644 index 0000000..8b56b47 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -0,0 +1,41 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderAuditProjection + { + public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log) + { + return new WorkOrderAuditEntryDto + { + Type = MapEventType(log.EventType), + DispatcherId = log.UserId, + Action = log.Action ?? "", + FieldName = log.FieldName, + OldValue = log.OldValue ?? "", + NewValue = log.NewValue ?? "", + Time = log.CreatedAt.ToString("o"), + DispatchId = log.DispatchId + }; + } + + public static List ToDtos(IEnumerable logs) + => logs.Select(ToDto).ToList(); + + private static string MapEventType(string? eventType) + { + if (string.IsNullOrWhiteSpace(eventType)) + return "manual"; + + return eventType.ToLowerInvariant() switch + { + "manual" => "manual", + "system" => "system", + "sync" => "sync", + "vendor" => "vendor", + _ => eventType.ToLowerInvariant() + }; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs new file mode 100644 index 0000000..096edd5 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -0,0 +1,23 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderCommentProjection + { + public static WorkOrderCommentDto ToDto(Comments comment) + { + return new WorkOrderCommentDto + { + Id = comment.Id, + AuthorId = comment.UserId, + Text = comment.Commenttext ?? "", + Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"), + Documents = comment.Documents + }; + } + + public static List ToDtos(IEnumerable comments) + => comments.Select(ToDto).ToList(); + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs new file mode 100644 index 0000000..cbacc09 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs @@ -0,0 +1,91 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderMediaProjection + { + public static List ProjectAll( + WorkOrder workOrder, + IEnumerable attachments) + { + var media = new List(); + var attachmentId = -1; + + if (!string.IsNullOrWhiteSpace(workOrder.BeforPhotoAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.Before, + Url = workOrder.BeforPhotoAttachment, + FileName = ExtractFileName(workOrder.BeforPhotoAttachment), + IsLegacy = true + }); + } + + if (!string.IsNullOrWhiteSpace(workOrder.AfterPhotoAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.After, + Url = workOrder.AfterPhotoAttachment, + FileName = ExtractFileName(workOrder.AfterPhotoAttachment), + IsLegacy = true + }); + } + + if (!string.IsNullOrWhiteSpace(workOrder.SignOffAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.Completion, + Url = workOrder.SignOffAttachment, + FileName = ExtractFileName(workOrder.SignOffAttachment), + IsLegacy = true + }); + } + + foreach (var attachment in attachments.Where(a => a.IsDeleted != true)) + { + if (string.IsNullOrWhiteSpace(attachment.Attachments)) + continue; + + media.Add(new WorkOrderMediaFileDto + { + Id = attachment.Id, + Category = attachment.Category ?? WorkOrderMediaCategory.Extra, + Url = attachment.Attachments, + FileName = ExtractFileName(attachment.Attachments), + UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), + IsLegacy = false + }); + } + + return media; + } + + private static string? ExtractFileName(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + var lastSlash = url.LastIndexOf('/'); + var segment = lastSlash >= 0 && lastSlash < url.Length - 1 + ? url[(lastSlash + 1)..] + : url; + + try + { + return Uri.UnescapeDataString(segment); + } + catch (Exception) + { + return segment; + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 73f871f..86087de 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -120,10 +120,16 @@ namespace SeaHaven.Services.Implementation private void StageFieldLock(int workOrderId, string fieldName, string? userId) { - var alreadyTracked = _context.WorkOrderFieldLocks.Local - .Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); + if (_context.WorkOrderFieldLocks.Local.Any(l => + l.WorkOrderId == workOrderId && + string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase))) + return; - if (alreadyTracked) + var existsInDb = _context.WorkOrderFieldLocks.Any(l => + l.WorkOrderId == workOrderId && + l.FieldName == fieldName); + + if (existsInDb) return; _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs new file mode 100644 index 0000000..d91599e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs @@ -0,0 +1,59 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCommentService : IWorkOrderCommentService + { + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICommentDataService _commentData; + + public WorkOrderCommentService( + IWorkOrderDetailDataService detailData, + ICommentDataService commentData) + { + _detailData = detailData; + _commentData = commentData; + } + + public async Task?> GetCommentsAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var comments = await _detailData.GetCommentsAsync(workOrderId); + return WorkOrderCommentProjection.ToDtos(comments); + } + + public async Task AddCommentAsync( + int workOrderId, + WorkOrderCommentCreateDto request, + string? actorId, + string? documentUrl = null) + { + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (string.IsNullOrWhiteSpace(request.Text)) + throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required."); + + var comment = new Comments + { + WorkerOrderId = workOrderId, + UserId = actorId, + Commenttext = request.Text.Trim(), + CommentType = "General", + RecordType = "WorkOrder", + Documents = documentUrl, + CreatedDate = DateTime.UtcNow + }; + + var saved = await _commentData.AddAsync(comment); + return WorkOrderCommentProjection.ToDto(saved); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs new file mode 100644 index 0000000..ccea868 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -0,0 +1,178 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCompletionService : IWorkOrderCompletionService + { + private readonly ApplicationDbContext _context; + private readonly ICompletionDocTemplateDataService _templateData; + private readonly IWorkOrderDetailDataService _detailData; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderCompletionService( + ApplicationDbContext context, + ICompletionDocTemplateDataService templateData, + IWorkOrderDetailDataService detailData, + IWorkOrderAuditService auditService) + { + _context = context; + _templateData = templateData; + _detailData = detailData; + _auditService = auditService; + } + + public async Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) + { + var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType); + return rows.Select(WorkOrderDetailService.MapTemplate).ToList(); + } + + public async Task GetTemplateByIdAsync(int id) + { + var row = await _templateData.GetByIdAsync(id); + return row == null ? null : WorkOrderDetailService.MapTemplate(row); + } + + public async Task UploadCompletionDocAsync( + int workOrderId, + WorkOrderCompletionDocUploadDto request, + string fileUrl, + string? actorId) + { + var workOrder = await _context.workOrders + .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + var workOrderVersion = ParseRowVersion(request.WorkOrderVersion); + if (workOrderVersion == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + + if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion)) + throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); + + _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion; + + var oldDocStatus = workOrder.DocStatus?.ToString(); + var oldAttachment = workOrder.SignOffAttachment; + + workOrder.SignOffAttachment = fileUrl; + if (!string.IsNullOrWhiteSpace(request.SignOffName)) + workOrder.SignOffName = request.SignOffName.Trim(); + if (!string.IsNullOrWhiteSpace(request.SignOffSignature)) + workOrder.SignOffSignature = request.SignOffSignature.Trim(); + + workOrder.DocStatus = DocStatus.Yes; + + _auditService.StageFieldChanged(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId); + if (oldDocStatus != DocStatus.Yes.ToString()) + _auditService.StageFieldChanged(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId); + + await _context.SaveChangesAsync(); + + var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); + var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType); + var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); + + return new WorkOrderCompletionDto + { + DocStatus = workOrder.DocStatus, + Template = template == null ? null : WorkOrderDetailService.MapTemplate(template), + SignOffName = extended?.SignOffName, + SignOffAttachment = extended?.SignOffAttachment, + SignOffSignature = extended?.SignOffSignature, + DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto + { + DispatchId = s.DispatchId, + SignoffType = s.SignoffType, + Name = s.Name, + SignedAt = s.SignedAt?.ToUniversalTime().ToString("o") + }).ToList() + }; + } + + public async Task CreateTemplateAsync(CompletionDocTemplateCreateDto request) + { + ValidateTemplateRequest(request); + + var entity = new CompletionDocTemplate + { + Name = request.Name.Trim(), + ServiceKey = request.ServiceKey.Trim(), + WorkOrderType = request.WorkOrderType, + TemplateUrl = request.TemplateUrl.Trim(), + IsActive = request.IsActive + }; + + var created = await _templateData.CreateAsync(entity); + return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( + created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive)); + } + + public async Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request) + { + ValidateTemplateRequest(request); + + var entity = await _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true); + if (entity == null) + throw new WorkOrderBoardValidationException("NotFound", "Template not found."); + + entity.Name = request.Name.Trim(); + entity.ServiceKey = request.ServiceKey.Trim(); + entity.WorkOrderType = request.WorkOrderType; + entity.TemplateUrl = request.TemplateUrl.Trim(); + entity.IsActive = request.IsActive; + + await _templateData.UpdateAsync(entity); + return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( + entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive)); + } + + public async Task DeleteTemplateAsync(int id) + { + if (!await _templateData.DeleteAsync(id)) + throw new WorkOrderBoardValidationException("NotFound", "Template not found."); + } + + private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request) + { + if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey)) + throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required."); + + if (string.IsNullOrWhiteSpace(request.TemplateUrl)) + throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required."); + + if (!Uri.TryCreate(request.TemplateUrl.Trim(), 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."); + } + } + + private static byte[]? ParseRowVersion(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return null; + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format."); + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs new file mode 100644 index 0000000..34aa8ac --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -0,0 +1,149 @@ +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderDetailService : IWorkOrderDetailService + { + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICompletionDocTemplateDataService _templateData; + + public WorkOrderDetailService( + IWorkOrderBoardService boardService, + IWorkOrderDetailDataService detailData, + ICompletionDocTemplateDataService templateData) + { + _boardService = boardService; + _detailData = detailData; + _templateData = templateData; + } + + public async Task GetDetailAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var boardRow = await _boardService.GetBoardRowAsync(workOrderId); + if (boardRow == null) + return null; + + var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); + var comments = await _detailData.GetCommentsAsync(workOrderId); + var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50); + var attachments = await _detailData.GetAttachmentsAsync(workOrderId); + var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + + var template = await _templateData.ResolveForWorkOrderAsync( + extended?.Trade ?? boardRow.Pm, + boardRow.WorkOrderType); + + return new WorkOrderDetailDto + { + Info = MapInfo(boardRow, extended), + Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs), + Comments = WorkOrderCommentProjection.ToDtos(comments), + Audit = WorkOrderAuditProjection.ToDtos(audit), + Media = workOrder == null + ? new List() + : WorkOrderMediaProjection.ProjectAll(workOrder, attachments) + }; + } + + public async Task?> GetAuditAsync(int workOrderId, int limit = 50) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); + return WorkOrderAuditProjection.ToDtos(logs); + } + + private static WorkOrderDetailInfoDto MapInfo( + WorkOrderBoardRowDto row, + WorkOrderDetailExtendedFields? extended) + { + return new WorkOrderDetailInfoDto + { + Id = row.Id, + WoNumber = row.WoNumber, + RescheduleCount = row.RescheduleCount, + CarriedOver = row.CarriedOver, + WorkOrderType = row.WorkOrderType, + IsPastDue = row.IsPastDue, + SiteCode = row.SiteCode, + LocationName = row.LocationName, + PocName = row.PocName, + PocPhone = row.PocPhone, + PocNotes = row.PocNotes, + LifecycleStatus = row.LifecycleStatus, + LifecycleStatusLabel = row.LifecycleStatusLabel, + LegacyStatus = row.LegacyStatus, + DispatcherId = row.DispatcherId, + DispatcherName = row.DispatcherName, + Initials = row.Initials, + Color = row.Color, + DueDate = row.DueDate, + ScheduledDate = row.ScheduledDate, + TargetWeek = row.TargetWeek, + ScheduleWeekOnly = row.ScheduleWeekOnly, + DayGroup = row.DayGroup, + VendorId = row.VendorId, + VendorName = row.VendorName, + TechName = row.TechName, + TechPhone = row.TechPhone, + ApptDate = row.ApptDate, + ApptTime = row.ApptTime, + Pm = row.Pm, + DocStatus = row.DocStatus, + PrimaryDispatchId = row.PrimaryDispatchId, + RowVersion = row.RowVersion, + DispatchRowVersion = row.DispatchRowVersion, + Description = extended?.Description, + Trade = extended?.Trade, + Problem = extended?.Problem, + SubTrade = extended?.SubTrade, + OriginalWeek = extended?.OriginalWeek, + OriginalDate = extended?.OriginalDate, + CompletedDate = extended?.CompletedDate + }; + } + + private static WorkOrderCompletionDto MapCompletion( + WorkOrderDetailExtendedFields? extended, + Data.SeaHavenIndustries.Enums.DocStatus? docStatus, + CompletionDocTemplateRow? template, + IReadOnlyList signoffs) + { + return new WorkOrderCompletionDto + { + DocStatus = docStatus, + Template = template == null ? null : MapTemplate(template), + SignOffName = extended?.SignOffName, + SignOffAttachment = extended?.SignOffAttachment, + SignOffSignature = extended?.SignOffSignature, + DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto + { + DispatchId = s.DispatchId, + SignoffType = s.SignoffType, + Name = s.Name, + SignedAt = s.SignedAt?.ToUniversalTime().ToString("o") + }).ToList() + }; + } + + internal static CompletionDocTemplateDto MapTemplate(CompletionDocTemplateRow template) + => new() + { + Id = template.Id, + Name = template.Name, + ServiceKey = template.ServiceKey, + WorkOrderType = template.WorkOrderType, + TemplateUrl = template.TemplateUrl, + IsActive = template.IsActive + }; + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs new file mode 100644 index 0000000..8de3380 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -0,0 +1,134 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderMediaService : IWorkOrderMediaService + { + private readonly ApplicationDbContext _context; + private readonly IWorkOrderDetailDataService _detailData; + + public WorkOrderMediaService(ApplicationDbContext context, IWorkOrderDetailDataService detailData) + { + _context = context; + _detailData = detailData; + } + + public async Task?> GetMediaAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + if (workOrder == null) + return null; + + var attachments = await _detailData.GetAttachmentsAsync(workOrderId); + return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); + } + + public async Task AddMediaAsync( + int workOrderId, + WorkOrderMediaCategory category, + string fileUrl, + string? actorId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + if (category == WorkOrderMediaCategory.Completion) + { + throw new WorkOrderBoardValidationException( + "UseCompletionDocEndpoint", + "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); + } + + if (category == WorkOrderMediaCategory.Before) + { + workOrder.BeforPhotoAttachment = fileUrl; + await _context.SaveChangesAsync(); + return new WorkOrderMediaFileDto + { + Id = -1, + Category = category, + Url = fileUrl, + IsLegacy = true + }; + } + + if (category == WorkOrderMediaCategory.After) + { + workOrder.AfterPhotoAttachment = fileUrl; + await _context.SaveChangesAsync(); + return new WorkOrderMediaFileDto + { + Id = -2, + Category = category, + Url = fileUrl, + IsLegacy = true + }; + } + + var attachment = new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl, + Category = category, + CreatedDate = DateTime.UtcNow, + createdby = actorId + }; + + _context.workOrderAttachments.Add(attachment); + await _context.SaveChangesAsync(); + + return new WorkOrderMediaFileDto + { + Id = attachment.Id, + Category = category, + Url = fileUrl, + UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), + IsLegacy = false + }; + } + + public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId) + { + if (mediaId <= 0) + throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); + + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + var attachment = await _context.workOrderAttachments + .FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true); + + if (attachment == null) + throw new WorkOrderBoardValidationException("NotFound", "Media not found."); + + attachment.IsDeleted = true; + attachment.DeletionTime = DateTime.UtcNow; + attachment.DeleterUserId = actorId; + await _context.SaveChangesAsync(); + } + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs new file mode 100644 index 0000000..9b1e2a2 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderCommentService + { + Task?> GetCommentsAsync(int workOrderId); + Task AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs new file mode 100644 index 0000000..333e906 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs @@ -0,0 +1,19 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderCompletionService + { + Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); + Task GetTemplateByIdAsync(int id); + Task UploadCompletionDocAsync( + int workOrderId, + WorkOrderCompletionDocUploadDto request, + string fileUrl, + string? actorId); + Task CreateTemplateAsync(CompletionDocTemplateCreateDto request); + Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request); + Task DeleteTemplateAsync(int id); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs new file mode 100644 index 0000000..c59f968 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs @@ -0,0 +1,10 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderDetailService + { + Task GetDetailAsync(int workOrderId); + Task?> GetAuditAsync(int workOrderId, int limit = 50); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs new file mode 100644 index 0000000..015cc83 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderMediaService + { + Task?> GetMediaAsync(int workOrderId); + Task AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); + Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); + } +}