mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
feat(work-orders): allow comment edit and resolve author audit display names (#24)
* feat(work-orders): enrich board search overdue filters and 0-based paging * fix(work-orders): align stacked services with CI build * fix(tests): pass userDataService in comment service unit test * fix(work-orders): use dedicated overdue query flag Stop treating WorkOrderType.Other as an overdue sentinel. Board and advanced search now accept overdue=true while types=Other filters real Other rows; combining both uses OR. * feat(work-orders): allow comment edit and resolve author audit display names Add PATCH comment for author/Admin, return authorName, and resolve AssignTo audit values to user display names. * fix(work-orders): enforce author-only comment edits per SH-122 Remove the undocumented Admin override so only the original comment author can edit, matching the ticket acceptance criteria. --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
This commit is contained in:
parent
620a36af54
commit
8f492c0faf
5 changed files with 279 additions and 9 deletions
|
|
@ -36,10 +36,10 @@ public class WorkOrderRouteContractTests
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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. Every action is reachable under both api/WorkOrder and
|
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||||
/// api/workorders; that base-route duplication is collapsed here, so this is the distinct
|
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||||
/// action-relative contract. 45 routes come from 43 actions (Editworkorder and
|
/// duplication is collapsed here, so this is the distinct action-relative contract. 46 routes
|
||||||
/// GetWorkorderById each bind two routes).
|
/// come from 44 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)
|
||||||
{
|
{
|
||||||
|
|
@ -67,6 +67,7 @@ public class WorkOrderRouteContractTests
|
||||||
"GET {id:int}/detail",
|
"GET {id:int}/detail",
|
||||||
"GET {id:int}/media",
|
"GET {id:int}/media",
|
||||||
"PATCH {id:int}/board",
|
"PATCH {id:int}/board",
|
||||||
|
"PATCH {id:int}/comments/{commentId:int}",
|
||||||
"POST AddChecklistItem",
|
"POST AddChecklistItem",
|
||||||
"POST AddComment",
|
"POST AddComment",
|
||||||
"POST AddCommentJson",
|
"POST AddCommentJson",
|
||||||
|
|
|
||||||
|
|
@ -71,5 +71,33 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}/comments/{commentId:int}")]
|
||||||
|
public async Task<IActionResult> UpdateBoardComment(
|
||||||
|
int id,
|
||||||
|
int commentId,
|
||||||
|
[FromBody] WorkOrderCommentCreateDto request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var comment = await _workOrderCommentService.UpdateCommentAsync(
|
||||||
|
id, commentId, 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) when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
|
}
|
||||||
|
catch (WorkOrderBoardValidationException ex)
|
||||||
|
{
|
||||||
|
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
int commentId,
|
int commentId,
|
||||||
WorkOrderCommentCreateDto request,
|
WorkOrderCommentCreateDto request,
|
||||||
string? actorId,
|
string? actorId)
|
||||||
bool isAdmin)
|
|
||||||
{
|
{
|
||||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||||
if (workOrder == null)
|
if (workOrder == null)
|
||||||
|
|
@ -89,7 +88,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
var isAuthor = !string.IsNullOrWhiteSpace(actorId)
|
var isAuthor = !string.IsNullOrWhiteSpace(actorId)
|
||||||
&& string.Equals(comment.UserId, actorId, StringComparison.Ordinal);
|
&& string.Equals(comment.UserId, actorId, StringComparison.Ordinal);
|
||||||
if (!isAuthor && !isAdmin)
|
if (!isAuthor)
|
||||||
throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to edit this comment.");
|
throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to edit this comment.");
|
||||||
|
|
||||||
comment.Commenttext = request.Text.Trim();
|
comment.Commenttext = request.Text.Trim();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ namespace SeaHaven.Services.Interfaces
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
int commentId,
|
int commentId,
|
||||||
WorkOrderCommentCreateDto request,
|
WorkOrderCommentCreateDto request,
|
||||||
string? actorId,
|
string? actorId);
|
||||||
bool isAdmin);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,13 @@ public class WorkOrderDetailServiceTests
|
||||||
NewValue = "1",
|
NewValue = "1",
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "user-1",
|
||||||
|
UserName = "alice",
|
||||||
|
FirstName = "Alice",
|
||||||
|
LastName = "Dispatcher"
|
||||||
|
});
|
||||||
context.Comments.Add(new Comments
|
context.Comments.Add(new Comments
|
||||||
{
|
{
|
||||||
WorkerOrderId = 1,
|
WorkerOrderId = 1,
|
||||||
|
|
@ -152,11 +159,75 @@ public class WorkOrderDetailServiceTests
|
||||||
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
|
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
|
||||||
Assert.Single(detail.Comments);
|
Assert.Single(detail.Comments);
|
||||||
Assert.Equal("user-1", detail.Comments[0].AuthorId);
|
Assert.Equal("user-1", detail.Comments[0].AuthorId);
|
||||||
|
Assert.Equal("Alice Dispatcher", detail.Comments[0].AuthorName);
|
||||||
Assert.Equal("Called vendor", detail.Comments[0].Text);
|
Assert.Equal("Called vendor", detail.Comments[0].Text);
|
||||||
Assert.Single(detail.Audit);
|
Assert.Single(detail.Audit);
|
||||||
Assert.Equal("system", detail.Audit[0].Type);
|
Assert.Equal("system", detail.Audit[0].Type);
|
||||||
Assert.Equal("WeekRolled", detail.Audit[0].Action);
|
Assert.Equal("WeekRolled", detail.Audit[0].Action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
const string oldUserId = "a1000001-0001-4000-8000-000000000002";
|
||||||
|
const string newUserId = "b5e356d5-d926-4f92-8673-ee13cddeff0f";
|
||||||
|
|
||||||
|
context.Users.AddRange(
|
||||||
|
new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = oldUserId,
|
||||||
|
UserName = "alice",
|
||||||
|
FirstName = "Alice",
|
||||||
|
LastName = "Dispatcher"
|
||||||
|
},
|
||||||
|
new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = newUserId,
|
||||||
|
UserName = "bob",
|
||||||
|
FirstName = "Bob",
|
||||||
|
LastName = "Tech"
|
||||||
|
});
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "00000000001",
|
||||||
|
LifecycleStatus = LifecycleStatus.Pending,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = 1,
|
||||||
|
UserId = "actor-1",
|
||||||
|
EventType = "Manual",
|
||||||
|
Action = "AssignmentChanged",
|
||||||
|
FieldName = "AssignTo",
|
||||||
|
OldValue = oldUserId,
|
||||||
|
NewValue = newUserId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = 1,
|
||||||
|
UserId = "actor-1",
|
||||||
|
EventType = "Manual",
|
||||||
|
Action = "AssignmentChanged",
|
||||||
|
FieldName = "AssignTo",
|
||||||
|
OldValue = newUserId,
|
||||||
|
NewValue = "",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddMinutes(1)
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var audit = await service.GetAuditAsync(1);
|
||||||
|
|
||||||
|
Assert.NotNull(audit);
|
||||||
|
Assert.Equal(2, audit!.Count);
|
||||||
|
var unassigned = audit.Single(a => a.NewValue == "Unassigned");
|
||||||
|
Assert.Equal("Bob Tech", unassigned.OldValue);
|
||||||
|
var reassigned = audit.Single(a => a.NewValue == "Bob Tech");
|
||||||
|
Assert.Equal("Alice Dispatcher", reassigned.OldValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WorkOrderCompletionServiceTests
|
public class WorkOrderCompletionServiceTests
|
||||||
|
|
@ -309,6 +380,13 @@ public class WorkOrderCommentServiceTests
|
||||||
.Options;
|
.Options;
|
||||||
var context = new ApplicationDbContext(options);
|
var context = new ApplicationDbContext(options);
|
||||||
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
|
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "user-abc",
|
||||||
|
UserName = "bob",
|
||||||
|
FirstName = "Bob",
|
||||||
|
LastName = "Tech"
|
||||||
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var detailData = new WorkOrderDetailDataService(context);
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
|
|
@ -319,9 +397,174 @@ public class WorkOrderCommentServiceTests
|
||||||
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
|
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
|
||||||
|
|
||||||
Assert.Equal("user-abc", result.AuthorId);
|
Assert.Equal("user-abc", result.AuthorId);
|
||||||
|
Assert.Equal("Bob Tech", result.AuthorName);
|
||||||
Assert.Equal("Note", result.Text);
|
Assert.Equal("Note", result.Text);
|
||||||
Assert.False(string.IsNullOrWhiteSpace(result.Time));
|
Assert.False(string.IsNullOrWhiteSpace(result.Time));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetComments_FallsBackToCommenterWhenUserIdMissing()
|
||||||
|
{
|
||||||
|
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.Comments.Add(new Comments
|
||||||
|
{
|
||||||
|
WorkerOrderId = 1,
|
||||||
|
Commenter = "Legacy Sync Author",
|
||||||
|
Commenttext = "Synced note",
|
||||||
|
CreatedDate = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
|
var commentData = new CommentDataService(context);
|
||||||
|
var userData = new UserDataService(context);
|
||||||
|
var service = new WorkOrderCommentService(detailData, commentData, userData);
|
||||||
|
|
||||||
|
var comments = await service.GetCommentsAsync(1);
|
||||||
|
|
||||||
|
Assert.NotNull(comments);
|
||||||
|
Assert.Single(comments!);
|
||||||
|
Assert.Null(comments[0].AuthorId);
|
||||||
|
Assert.Equal("Legacy Sync Author", comments[0].AuthorName);
|
||||||
|
Assert.Equal("Synced note", comments[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(ApplicationDbContext Context, WorkOrderCommentService Service, Comments Comment)> SeedEditableCommentAsync(
|
||||||
|
LifecycleStatus status = LifecycleStatus.Scheduled,
|
||||||
|
string authorId = "user-author",
|
||||||
|
string commentType = "General",
|
||||||
|
string recordType = "WorkOrder",
|
||||||
|
int workOrderId = 1)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
context.workOrders.Add(new WorkOrder { Id = workOrderId, LifecycleStatus = status });
|
||||||
|
context.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = authorId,
|
||||||
|
UserName = "author",
|
||||||
|
FirstName = "Alice",
|
||||||
|
LastName = "Dispatcher"
|
||||||
|
});
|
||||||
|
var comment = new Comments
|
||||||
|
{
|
||||||
|
WorkerOrderId = workOrderId,
|
||||||
|
UserId = authorId,
|
||||||
|
Commenttext = "Original",
|
||||||
|
CommentType = commentType,
|
||||||
|
RecordType = recordType,
|
||||||
|
CreatedDate = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
context.Comments.Add(comment);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new WorkOrderCommentService(
|
||||||
|
new WorkOrderDetailDataService(context),
|
||||||
|
new CommentDataService(context),
|
||||||
|
new UserDataService(context));
|
||||||
|
return (context, service, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateComment_Author_UpdatesText()
|
||||||
|
{
|
||||||
|
var (_, service, comment) = await SeedEditableCommentAsync();
|
||||||
|
|
||||||
|
var result = await service.UpdateCommentAsync(
|
||||||
|
1,
|
||||||
|
comment.Id,
|
||||||
|
new WorkOrderCommentCreateDto { Text = " Updated note " },
|
||||||
|
"user-author");
|
||||||
|
|
||||||
|
Assert.Equal("Updated note", result.Text);
|
||||||
|
Assert.Equal("user-author", result.AuthorId);
|
||||||
|
Assert.Equal("Alice Dispatcher", result.AuthorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateComment_AdminNonAuthor_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
var (_, service, comment) = await SeedEditableCommentAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.UpdateCommentAsync(
|
||||||
|
1,
|
||||||
|
comment.Id,
|
||||||
|
new WorkOrderCommentCreateDto { Text = "Admin edit" },
|
||||||
|
"admin-user"));
|
||||||
|
|
||||||
|
Assert.Equal("Forbidden", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateComment_NonAuthor_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
var (_, service, comment) = await SeedEditableCommentAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.UpdateCommentAsync(
|
||||||
|
1,
|
||||||
|
comment.Id,
|
||||||
|
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||||
|
"other-user"));
|
||||||
|
|
||||||
|
Assert.Equal("Forbidden", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateComment_LegacyVendorComment_ThrowsNotEditable()
|
||||||
|
{
|
||||||
|
var (_, service, comment) = await SeedEditableCommentAsync(
|
||||||
|
commentType: "vendor",
|
||||||
|
recordType: "WorkOrder");
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.UpdateCommentAsync(
|
||||||
|
1,
|
||||||
|
comment.Id,
|
||||||
|
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||||
|
"user-author"));
|
||||||
|
|
||||||
|
Assert.Equal("NotEditable", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateComment_ReadOnlyWorkOrder_Throws()
|
||||||
|
{
|
||||||
|
var (_, service, comment) = await SeedEditableCommentAsync(LifecycleStatus.Canceled);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.UpdateCommentAsync(
|
||||||
|
1,
|
||||||
|
comment.Id,
|
||||||
|
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||||
|
"user-author"));
|
||||||
|
|
||||||
|
Assert.Equal("ReadOnly", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateComment_WrongWorkOrder_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
var (context, service, comment) = await SeedEditableCommentAsync(workOrderId: 1);
|
||||||
|
context.workOrders.Add(new WorkOrder { Id = 2, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.UpdateCommentAsync(
|
||||||
|
2,
|
||||||
|
comment.Id,
|
||||||
|
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||||
|
"user-author"));
|
||||||
|
|
||||||
|
Assert.Equal("NotFound", ex.Code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WorkOrderMediaServiceTests
|
public class WorkOrderMediaServiceTests
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue