mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 19:22:12 +00:00
feat(work-orders): board completedDate + media categorize contract
Expose completedDate on PATCH /workorders/{id}/board so CompDoc can leave legacy EditWorkorder. Allow optional media category on upload, PATCH category afterward, and enforce JPG/PNG/MP4/MOV allowlist (SH-116).
This commit is contained in:
parent
a7b1f3bc40
commit
d073a503d1
14 changed files with 280 additions and 13 deletions
|
|
@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
|
||||||
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
||||||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||||
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 46 routes
|
/// duplication is collapsed here, so this is the distinct action-relative contract. 47 routes
|
||||||
/// come from 44 actions (Editworkorder and GetWorkorderById each bind two routes).
|
/// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
|
@ -68,6 +68,7 @@ public class WorkOrderRouteContractTests
|
||||||
"GET {id:int}/media",
|
"GET {id:int}/media",
|
||||||
"PATCH {id:int}/board",
|
"PATCH {id:int}/board",
|
||||||
"PATCH {id:int}/comments/{commentId:int}",
|
"PATCH {id:int}/comments/{commentId:int}",
|
||||||
|
"PATCH {id:int}/media/{mediaId:int}",
|
||||||
"POST AddChecklistItem",
|
"POST AddChecklistItem",
|
||||||
"POST AddComment",
|
"POST AddComment",
|
||||||
"POST AddCommentJson",
|
"POST AddCommentJson",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
|
@ -40,7 +41,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[RequestSizeLimit(30_000_000)]
|
[RequestSizeLimit(30_000_000)]
|
||||||
public async Task<IActionResult> AddMedia(
|
public async Task<IActionResult> AddMedia(
|
||||||
int id,
|
int id,
|
||||||
[FromForm] WorkOrderMediaCategory category,
|
[FromForm] WorkOrderMediaCategory? category,
|
||||||
[FromForm] IFormFile file)
|
[FromForm] IFormFile file)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
if (file == null || file.Length == 0)
|
||||||
|
|
@ -48,6 +49,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
WorkOrderMediaFileRules.EnsureAllowed(file);
|
||||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var fileUrl = await _fileStorage.SaveFileAsync(file);
|
var fileUrl = await _fileStorage.SaveFileAsync(file);
|
||||||
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
|
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
|
||||||
|
|
@ -63,6 +65,28 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}/media/{mediaId:int}")]
|
||||||
|
public async Task<IActionResult> UpdateMediaCategory(
|
||||||
|
int id,
|
||||||
|
int mediaId,
|
||||||
|
[FromForm] WorkOrderMediaCategory category)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var media = await _workOrderMediaService.UpdateMediaCategoryAsync(id, mediaId, category, 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}")]
|
[HttpDelete("{id:int}/media/{mediaId:int}")]
|
||||||
public async Task<IActionResult> DeleteMedia(int id, int mediaId)
|
public async Task<IActionResult> DeleteMedia(int id, int mediaId)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.ServiceNotes,
|
w.ServiceNotes,
|
||||||
w.ExtraServices,
|
w.ExtraServices,
|
||||||
w.DocStatus,
|
w.DocStatus,
|
||||||
|
w.CompletedDate,
|
||||||
w.FlagColor,
|
w.FlagColor,
|
||||||
w.PrimaryDispatchId,
|
w.PrimaryDispatchId,
|
||||||
w.RowVersion,
|
w.RowVersion,
|
||||||
|
|
@ -116,6 +117,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.ServiceNotes,
|
w.ServiceNotes,
|
||||||
w.ExtraServices,
|
w.ExtraServices,
|
||||||
w.DocStatus,
|
w.DocStatus,
|
||||||
|
w.CompletedDate,
|
||||||
w.FlagColor,
|
w.FlagColor,
|
||||||
w.PrimaryDispatchId,
|
w.PrimaryDispatchId,
|
||||||
w.RowVersion,
|
w.RowVersion,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
string? ServiceNotes,
|
string? ServiceNotes,
|
||||||
string? ExtraServices,
|
string? ExtraServices,
|
||||||
DocStatus? DocStatus,
|
DocStatus? DocStatus,
|
||||||
|
DateTime? CompletedDate,
|
||||||
string? FlagColor,
|
string? FlagColor,
|
||||||
int? PrimaryDispatchId,
|
int? PrimaryDispatchId,
|
||||||
byte[]? RowVersion,
|
byte[]? RowVersion,
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? ServiceNotes { get; set; }
|
public string? ServiceNotes { get; set; }
|
||||||
public List<string>? ExtraServices { get; set; }
|
public List<string>? ExtraServices { get; set; }
|
||||||
public DocStatus? DocStatus { get; set; }
|
public DocStatus? DocStatus { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
|
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
|
||||||
public string? FlagColor { get; set; }
|
public string? FlagColor { get; set; }
|
||||||
public int? PrimaryDispatchId { get; set; }
|
public int? PrimaryDispatchId { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? SubTrade { get; set; }
|
public string? SubTrade { get; set; }
|
||||||
public DateOnly? OriginalWeek { get; set; }
|
public DateOnly? OriginalWeek { get; set; }
|
||||||
public DateOnly? OriginalDate { get; set; }
|
public DateOnly? OriginalDate { get; set; }
|
||||||
public DateTime? CompletedDate { get; set; }
|
|
||||||
public string? VendorNotes { get; set; }
|
public string? VendorNotes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
public const string ApptDate = "apptDate";
|
public const string ApptDate = "apptDate";
|
||||||
public const string ApptTime = "apptTime";
|
public const string ApptTime = "apptTime";
|
||||||
public const string DocStatus = "docStatus";
|
public const string DocStatus = "docStatus";
|
||||||
|
public const string CompletedDate = "completedDate";
|
||||||
public const string Pm = "pm";
|
public const string Pm = "pm";
|
||||||
public const string ServiceNotes = "serviceNotes";
|
public const string ServiceNotes = "serviceNotes";
|
||||||
public const string ExtraServices = "extraServices";
|
public const string ExtraServices = "extraServices";
|
||||||
|
|
@ -43,6 +44,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
ApptDate,
|
ApptDate,
|
||||||
ApptTime,
|
ApptTime,
|
||||||
DocStatus,
|
DocStatus,
|
||||||
|
CompletedDate,
|
||||||
Pm,
|
Pm,
|
||||||
ServiceNotes,
|
ServiceNotes,
|
||||||
ExtraServices,
|
ExtraServices,
|
||||||
|
|
@ -83,6 +85,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
ApptDate => "ApptDate",
|
ApptDate => "ApptDate",
|
||||||
ApptTime => "ApptTime",
|
ApptTime => "ApptTime",
|
||||||
DocStatus => "DocStatus",
|
DocStatus => "DocStatus",
|
||||||
|
CompletedDate => "CompletedDate",
|
||||||
Pm => "Trade",
|
Pm => "Trade",
|
||||||
ServiceNotes => "ServiceNotes",
|
ServiceNotes => "ServiceNotes",
|
||||||
ExtraServices => "ExtraServices",
|
ExtraServices => "ExtraServices",
|
||||||
|
|
|
||||||
51
SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
Normal file
51
SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>SH-116 media type allowlist for board work-order uploads.</summary>
|
||||||
|
public static class WorkOrderMediaFileRules
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".mp4",
|
||||||
|
".mov"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsAllowed(IFormFile file)
|
||||||
|
{
|
||||||
|
if (file == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var contentTypeOk = !string.IsNullOrWhiteSpace(file.ContentType)
|
||||||
|
&& AllowedContentTypes.Contains(file.ContentType.Trim());
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(file.FileName ?? "");
|
||||||
|
var extensionOk = !string.IsNullOrWhiteSpace(extension)
|
||||||
|
&& AllowedExtensions.Contains(extension);
|
||||||
|
|
||||||
|
// Accept when either MIME or extension matches the advertised contract (JPG/PNG/MP4/MOV).
|
||||||
|
return contentTypeOk || extensionOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void EnsureAllowed(IFormFile file)
|
||||||
|
{
|
||||||
|
if (!IsAllowed(file))
|
||||||
|
{
|
||||||
|
throw new Exceptions.WorkOrderBoardValidationException(
|
||||||
|
"UnsupportedMediaType",
|
||||||
|
"Supported media types are JPG, PNG, MP4, and MOV.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -113,6 +113,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
ServiceNotes = row.ServiceNotes,
|
ServiceNotes = row.ServiceNotes,
|
||||||
ExtraServices = ParseExtraServices(row.ExtraServices),
|
ExtraServices = ParseExtraServices(row.ExtraServices),
|
||||||
DocStatus = row.DocStatus,
|
DocStatus = row.DocStatus,
|
||||||
|
CompletedDate = row.CompletedDate,
|
||||||
FlagColor = row.FlagColor,
|
FlagColor = row.FlagColor,
|
||||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||||
RowVersion = row.RowVersion,
|
RowVersion = row.RowVersion,
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
|
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
|
||||||
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.CompletedDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.CompletedDate = v, () => workOrder.CompletedDate) },
|
||||||
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
|
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
|
||||||
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
|
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
WorkOrderMediaCategory category,
|
WorkOrderMediaCategory? category,
|
||||||
string fileUrl,
|
string fileUrl,
|
||||||
string? actorId)
|
string? actorId)
|
||||||
{
|
{
|
||||||
|
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
||||||
|
|
||||||
if (!await _detailData.ExistsAsync(workOrderId))
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
|
@ -48,34 +50,34 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
|
|
||||||
if (category == WorkOrderMediaCategory.Completion)
|
if (resolvedCategory == WorkOrderMediaCategory.Completion)
|
||||||
{
|
{
|
||||||
throw new WorkOrderBoardValidationException(
|
throw new WorkOrderBoardValidationException(
|
||||||
"UseCompletionDocEndpoint",
|
"UseCompletionDocEndpoint",
|
||||||
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
|
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category == WorkOrderMediaCategory.Before)
|
if (resolvedCategory == WorkOrderMediaCategory.Before)
|
||||||
{
|
{
|
||||||
workOrder.BeforPhotoAttachment = fileUrl;
|
workOrder.BeforPhotoAttachment = fileUrl;
|
||||||
await _mediaData.SaveAsync(CancellationToken.None);
|
await _mediaData.SaveAsync(CancellationToken.None);
|
||||||
return new WorkOrderMediaFileDto
|
return new WorkOrderMediaFileDto
|
||||||
{
|
{
|
||||||
Id = -1,
|
Id = -1,
|
||||||
Category = category,
|
Category = resolvedCategory,
|
||||||
Url = fileUrl,
|
Url = fileUrl,
|
||||||
IsLegacy = true
|
IsLegacy = true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category == WorkOrderMediaCategory.After)
|
if (resolvedCategory == WorkOrderMediaCategory.After)
|
||||||
{
|
{
|
||||||
workOrder.AfterPhotoAttachment = fileUrl;
|
workOrder.AfterPhotoAttachment = fileUrl;
|
||||||
await _mediaData.SaveAsync(CancellationToken.None);
|
await _mediaData.SaveAsync(CancellationToken.None);
|
||||||
return new WorkOrderMediaFileDto
|
return new WorkOrderMediaFileDto
|
||||||
{
|
{
|
||||||
Id = -2,
|
Id = -2,
|
||||||
Category = category,
|
Category = resolvedCategory,
|
||||||
Url = fileUrl,
|
Url = fileUrl,
|
||||||
IsLegacy = true
|
IsLegacy = true
|
||||||
};
|
};
|
||||||
|
|
@ -85,7 +87,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
WorkorderId = workOrderId,
|
WorkorderId = workOrderId,
|
||||||
Attachments = fileUrl,
|
Attachments = fileUrl,
|
||||||
Category = category,
|
Category = category.HasValue ? resolvedCategory : null,
|
||||||
CreatedDate = DateTime.UtcNow,
|
CreatedDate = DateTime.UtcNow,
|
||||||
createdby = actorId
|
createdby = actorId
|
||||||
};
|
};
|
||||||
|
|
@ -96,13 +98,78 @@ namespace SeaHaven.Services.Implementation
|
||||||
return new WorkOrderMediaFileDto
|
return new WorkOrderMediaFileDto
|
||||||
{
|
{
|
||||||
Id = attachment.Id,
|
Id = attachment.Id,
|
||||||
Category = category,
|
Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
|
||||||
Url = fileUrl,
|
Url = fileUrl,
|
||||||
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
|
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
|
||||||
IsLegacy = false
|
IsLegacy = false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderMediaFileDto> UpdateMediaCategoryAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int mediaId,
|
||||||
|
WorkOrderMediaCategory category,
|
||||||
|
string? actorId)
|
||||||
|
{
|
||||||
|
if (mediaId <= 0)
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint.");
|
||||||
|
|
||||||
|
if (category == WorkOrderMediaCategory.Completion)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"UseCompletionDocEndpoint",
|
||||||
|
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
|
||||||
|
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 _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None);
|
||||||
|
if (attachment == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
|
||||||
|
|
||||||
|
if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After)
|
||||||
|
{
|
||||||
|
var url = attachment.Attachments ?? "";
|
||||||
|
if (category == WorkOrderMediaCategory.Before)
|
||||||
|
workOrder.BeforPhotoAttachment = url;
|
||||||
|
else
|
||||||
|
workOrder.AfterPhotoAttachment = url;
|
||||||
|
|
||||||
|
attachment.IsDeleted = true;
|
||||||
|
attachment.DeletionTime = DateTime.UtcNow;
|
||||||
|
attachment.DeleterUserId = actorId;
|
||||||
|
await _mediaData.SaveAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
return new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = category == WorkOrderMediaCategory.Before ? -1 : -2,
|
||||||
|
Category = category,
|
||||||
|
Url = url,
|
||||||
|
IsLegacy = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment.Category = category;
|
||||||
|
await _mediaData.SaveAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
return new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = attachment.Id,
|
||||||
|
Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
|
||||||
|
Url = attachment.Attachments ?? "",
|
||||||
|
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
|
||||||
|
IsLegacy = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId)
|
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId)
|
||||||
{
|
{
|
||||||
if (mediaId <= 0)
|
if (mediaId <= 0)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,16 @@ namespace SeaHaven.Services.Interfaces
|
||||||
public interface IWorkOrderMediaService
|
public interface IWorkOrderMediaService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
|
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
|
||||||
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
|
Task<WorkOrderMediaFileDto> AddMediaAsync(
|
||||||
|
int workOrderId,
|
||||||
|
WorkOrderMediaCategory? category,
|
||||||
|
string fileUrl,
|
||||||
|
string? actorId);
|
||||||
|
Task<WorkOrderMediaFileDto> UpdateMediaCategoryAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int mediaId,
|
||||||
|
WorkOrderMediaCategory category,
|
||||||
|
string? actorId);
|
||||||
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
|
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -798,4 +798,59 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
|
|
||||||
Assert.Equal("VendorNotFound", ex.Code);
|
Assert.Equal("VendorNotFound", ex.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_UpdatesCompletedDate()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
context.WorkOrderContacts.Add(new WorkOrderContacts { Id = 1, WorkorderId = 1, ContactId = 50 });
|
||||||
|
context.workOrderCategories.Add(new WorkOrderCategories { Id = 1, WorkorderId = 1, CategoryId = 60 });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.CompletedDate,
|
||||||
|
Value = "2026-07-20",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 20), result.CompletedDate);
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 20), context.workOrders.Single().CompletedDate);
|
||||||
|
Assert.Equal(1, await context.WorkOrderContacts.CountAsync());
|
||||||
|
Assert.Equal(1, await context.workOrderCategories.CountAsync());
|
||||||
|
Assert.Equal(50, context.WorkOrderContacts.Single().ContactId);
|
||||||
|
Assert.Equal(60, context.workOrderCategories.Single().CategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_ClearsCompletedDate_WhenValueEmpty()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
CompletedDate = new DateTime(2026, 7, 1),
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.CompletedDate,
|
||||||
|
Value = "",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Null(result.CompletedDate);
|
||||||
|
Assert.Null(context.workOrders.Single().CompletedDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -650,4 +650,56 @@ public class WorkOrderMediaServiceTests
|
||||||
|
|
||||||
Assert.Equal("UseCompletionDocEndpoint", ex.Code);
|
Assert.Equal("UseCompletionDocEndpoint", ex.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_WithoutCategory_PersistsAsUncategorizedExtra()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
|
||||||
|
var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", "actor-1");
|
||||||
|
|
||||||
|
Assert.True(media.Id > 0);
|
||||||
|
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
|
||||||
|
Assert.Null(context.workOrderAttachments.Single().Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/photo.jpg",
|
||||||
|
Category = null
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context));
|
||||||
|
var media = await service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(-1, media.Id);
|
||||||
|
Assert.Equal(WorkOrderMediaCategory.Before, media.Category);
|
||||||
|
Assert.Equal("https://example.com/photo.jpg", context.workOrders.Single().BeforPhotoAttachment);
|
||||||
|
Assert.True(context.workOrderAttachments.Single().IsDeleted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue