feat(work-orders): add Phase 6 slide-over detail comments audit and media API

This commit is contained in:
Arthur Bassi 2026-07-13 13:13:00 -03:00
parent 0ce008e5eb
commit af84001bf1
18 changed files with 1313 additions and 4 deletions

View file

@ -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<ApplicationUser> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Addworkorder([FromForm] Workorder_DTO model)

View file

@ -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<IReadOnlyList<CompletionDocTemplateRow>> 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<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> 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<CompletionDocTemplate> 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<bool> 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<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}
}

View file

@ -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<bool> ExistsAsync(int workOrderId)
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.AnyAsync(w => w.Id == workOrderId);
public async Task<WorkOrderDetailExtendedFields?> 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<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId)
{
return await _context.Comments
.AsNoTracking()
.Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true)
.OrderByDescending(c => c.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<WorkOrderAuditLog>> 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<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
.OrderByDescending(a => a.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<DispatchSignoffRow>> 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<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
{
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.FirstOrDefaultAsync(w => w.Id == workOrderId);
}
}
}

View file

@ -0,0 +1,27 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderDetailDataService
{
Task<bool> ExistsAsync(int workOrderId);
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
}
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
}
}

View file

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

View file

@ -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<WorkOrderCommentDto> Comments { get; set; } = new();
public List<WorkOrderAuditEntryDto> Audit { get; set; } = new();
public List<WorkOrderMediaFileDto> 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<DispatchSignoffDto> 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; }
}
}

View file

@ -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<WorkOrderAuditEntryDto> ToDtos(IEnumerable<WorkOrderAuditLog> 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()
};
}
}
}

View file

@ -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<WorkOrderCommentDto> ToDtos(IEnumerable<Comments> comments)
=> comments.Select(ToDto).ToList();
}
}

View file

@ -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<WorkOrderMediaFileDto> ProjectAll(
WorkOrder workOrder,
IEnumerable<WorkOrderAttachments> attachments)
{
var media = new List<WorkOrderMediaFileDto>();
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;
}
}
}
}

View file

@ -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

View file

@ -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<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
{
if (!await _detailData.ExistsAsync(workOrderId))
return null;
var comments = await _detailData.GetCommentsAsync(workOrderId);
return WorkOrderCommentProjection.ToDtos(comments);
}
public async Task<WorkOrderCommentDto> 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);
}
}
}

View file

@ -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<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType);
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
}
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
{
var row = await _templateData.GetByIdAsync(id);
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
}
public async Task<WorkOrderCompletionDto> 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<CompletionDocTemplateDto> 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<CompletionDocTemplateDto> 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.");
}
}
}
}

View file

@ -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<WorkOrderDetailDto?> 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<WorkOrderMediaFileDto>()
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
};
}
public async Task<IReadOnlyList<WorkOrderAuditEntryDto>?> 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<DispatchSignoffRow> 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
};
}
}

View file

@ -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<IReadOnlyList<WorkOrderMediaFileDto>?> 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<WorkOrderMediaFileDto> 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();
}
}
}

View file

@ -0,0 +1,11 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCommentService
{
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
}
}

View file

@ -0,0 +1,19 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCompletionService
{
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id);
}
}

View file

@ -0,0 +1,10 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderDetailService
{
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50);
}
}

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderMediaService
{
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
}
}