diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 365ceca..436a806 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -33,5 +33,27 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Users.AnyAsync(u => u.Id == id); } + + public async Task> GetDisplayNamesByIdsAsync(IEnumerable ids) + { + var idList = ids + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (idList.Count == 0) + return new Dictionary(StringComparer.Ordinal); + + var users = await _context.Users + .AsNoTracking() + .Where(u => idList.Contains(u.Id)) + .Select(u => new { u.Id, u.FirstName, u.LastName }) + .ToListAsync(); + + return users.ToDictionary( + u => u.Id, + u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(), + StringComparer.Ordinal); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 6524cc4..6cfed2d 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task GetByEmailAsync(string email); Task ExistsAsync(string id); + Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } } diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs index 8b56b47..6a7e295 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderAuditProjection { - public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log) + private const string UnassignedLabel = "Unassigned"; + + public static WorkOrderAuditEntryDto ToDto( + WorkOrderAuditLog log, + IReadOnlyDictionary? assignToDisplayNames = null) { + var oldValue = log.OldValue ?? ""; + var newValue = log.NewValue ?? ""; + + if (IsAssignToField(log.FieldName)) + { + oldValue = ResolveAssignToDisplay(oldValue, assignToDisplayNames); + newValue = ResolveAssignToDisplay(newValue, assignToDisplayNames); + } + return new WorkOrderAuditEntryDto { Type = MapEventType(log.EventType), DispatcherId = log.UserId, Action = log.Action ?? "", FieldName = log.FieldName, - OldValue = log.OldValue ?? "", - NewValue = log.NewValue ?? "", + OldValue = oldValue, + NewValue = newValue, Time = log.CreatedAt.ToString("o"), DispatchId = log.DispatchId }; } - public static List ToDtos(IEnumerable logs) - => logs.Select(ToDto).ToList(); + public static List ToDtos( + IEnumerable logs, + IReadOnlyDictionary? assignToDisplayNames = null) + => logs.Select(log => ToDto(log, assignToDisplayNames)).ToList(); + + public static IEnumerable CollectAssignToUserIds(IEnumerable logs) + { + return logs + .Where(l => IsAssignToField(l.FieldName)) + .SelectMany(l => new[] { l.OldValue, l.NewValue }) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .Distinct(StringComparer.Ordinal); + } + + internal static bool IsAssignToField(string? fieldName) + => string.Equals(fieldName, "AssignTo", StringComparison.OrdinalIgnoreCase) + || string.Equals(fieldName, "AssignedTo", StringComparison.OrdinalIgnoreCase); + + private static string ResolveAssignToDisplay( + string value, + IReadOnlyDictionary? assignToDisplayNames) + { + if (string.IsNullOrWhiteSpace(value)) + return UnassignedLabel; + + if (assignToDisplayNames != null + && assignToDisplayNames.TryGetValue(value, out var name) + && !string.IsNullOrWhiteSpace(name)) + { + return name; + } + + return value; + } private static string MapEventType(string? eventType) { diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs index 096edd5..cb4a2d5 100644 --- a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderCommentProjection { - public static WorkOrderCommentDto ToDto(Comments comment) + public static WorkOrderCommentDto ToDto( + Comments comment, + IReadOnlyDictionary? displayNames = null) { return new WorkOrderCommentDto { Id = comment.Id, AuthorId = comment.UserId, + AuthorName = ResolveAuthorName(comment, displayNames), Text = comment.Commenttext ?? "", Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"), Documents = comment.Documents }; } - public static List ToDtos(IEnumerable comments) - => comments.Select(ToDto).ToList(); + public static List ToDtos( + IEnumerable comments, + IReadOnlyDictionary? displayNames = null) + => comments.Select(c => ToDto(c, displayNames)).ToList(); + + public static IEnumerable CollectAuthorUserIds(IEnumerable comments) + => comments + .Where(c => !string.IsNullOrWhiteSpace(c.UserId)) + .Select(c => c.UserId!) + .Distinct(StringComparer.Ordinal); + + private static string? ResolveAuthorName( + Comments comment, + IReadOnlyDictionary? displayNames) + { + if (!string.IsNullOrWhiteSpace(comment.UserId) + && displayNames != null + && displayNames.TryGetValue(comment.UserId, out var name) + && !string.IsNullOrWhiteSpace(name)) + { + return name; + } + + if (!string.IsNullOrWhiteSpace(comment.Commenter)) + return comment.Commenter.Trim(); + + return null; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs index d91599e..da0098d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs @@ -11,13 +11,16 @@ namespace SeaHaven.Services.Implementation { private readonly IWorkOrderDetailDataService _detailData; private readonly ICommentDataService _commentData; + private readonly IUserDataService _userDataService; public WorkOrderCommentService( IWorkOrderDetailDataService detailData, - ICommentDataService commentData) + ICommentDataService commentData, + IUserDataService userDataService) { _detailData = detailData; _commentData = commentData; + _userDataService = userDataService; } public async Task?> GetCommentsAsync(int workOrderId) @@ -26,7 +29,8 @@ namespace SeaHaven.Services.Implementation return null; var comments = await _detailData.GetCommentsAsync(workOrderId); - return WorkOrderCommentProjection.ToDtos(comments); + var authorNames = await ResolveAuthorNamesAsync(comments); + return WorkOrderCommentProjection.ToDtos(comments, authorNames); } public async Task AddCommentAsync( @@ -53,7 +57,58 @@ namespace SeaHaven.Services.Implementation }; var saved = await _commentData.AddAsync(comment); - return WorkOrderCommentProjection.ToDto(saved); + var authorNames = await ResolveAuthorNamesAsync(new[] { saved }); + return WorkOrderCommentProjection.ToDto(saved, authorNames); + } + + public async Task UpdateCommentAsync( + int workOrderId, + int commentId, + WorkOrderCommentCreateDto request, + string? actorId, + bool isAdmin) + { + var workOrder = await _detailData.GetWorkOrderForMediaAsync(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 (string.IsNullOrWhiteSpace(request.Text)) + throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required."); + + var comment = await _commentData.GetByIdAsync(commentId); + if (comment == null || comment.WorkerOrderId != workOrderId || comment.IsDeleted == true) + throw new WorkOrderBoardValidationException("NotFound", "Comment not found."); + + if (!IsBoardEditableComment(comment)) + throw new WorkOrderBoardValidationException( + "NotEditable", + "Only board comments (CommentType General) can be edited via this endpoint."); + + var isAuthor = !string.IsNullOrWhiteSpace(actorId) + && string.Equals(comment.UserId, actorId, StringComparison.Ordinal); + if (!isAuthor && !isAdmin) + throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to edit this comment."); + + comment.Commenttext = request.Text.Trim(); + comment.LastModificationTime = DateTime.UtcNow; + await _commentData.UpdateAsync(comment); + + var authorNames = await ResolveAuthorNamesAsync(new[] { comment }); + return WorkOrderCommentProjection.ToDto(comment, authorNames); + } + + private static bool IsBoardEditableComment(Comments comment) + => string.Equals(comment.CommentType, "General", StringComparison.OrdinalIgnoreCase) + && string.Equals(comment.RecordType, "WorkOrder", StringComparison.OrdinalIgnoreCase); + + private async Task> ResolveAuthorNamesAsync( + IEnumerable comments) + { + var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments); + return await _userDataService.GetDisplayNamesByIdsAsync(authorIds); } } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs index 9b1e2a2..92c64bc 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs @@ -7,5 +7,11 @@ namespace SeaHaven.Services.Interfaces { Task?> GetCommentsAsync(int workOrderId); Task AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null); + Task UpdateCommentAsync( + int workOrderId, + int commentId, + WorkOrderCommentCreateDto request, + string? actorId, + bool isAdmin); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 343337e..6133102 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -92,7 +92,8 @@ public class WorkOrderDetailServiceTests var boardService = new WorkOrderBoardService(boardData); var detailData = new WorkOrderDetailDataService(context); var templateData = new CompletionDocTemplateDataService(context); - var service = new WorkOrderDetailService(boardService, detailData, templateData); + var userData = new UserDataService(context); + var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); return (context, service); } @@ -131,6 +132,13 @@ public class WorkOrderDetailServiceTests NewValue = "1", CreatedAt = DateTime.UtcNow }); + context.Users.Add(new ApplicationUser + { + Id = "user-1", + UserName = "alice", + FirstName = "Alice", + LastName = "Dispatcher" + }); context.Comments.Add(new Comments { WorkerOrderId = 1, @@ -150,11 +158,75 @@ public class WorkOrderDetailServiceTests Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name); Assert.Single(detail.Comments); 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.Single(detail.Audit); Assert.Equal("system", detail.Audit[0].Type); 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 @@ -306,18 +378,197 @@ public class WorkOrderCommentServiceTests .Options; var context = new ApplicationDbContext(options); 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(); var detailData = new WorkOrderDetailDataService(context); var commentData = new CommentDataService(context); - var service = new WorkOrderCommentService(detailData, commentData); + var userData = new UserDataService(context); + var service = new WorkOrderCommentService(detailData, commentData, userData); var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc"); Assert.Equal("user-abc", result.AuthorId); + Assert.Equal("Bob Tech", result.AuthorName); Assert.Equal("Note", result.Text); Assert.False(string.IsNullOrWhiteSpace(result.Time)); } + + [Fact] + public async Task GetComments_FallsBackToCommenterWhenUserIdMissing() + { + var options = new DbContextOptionsBuilder() + .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() + .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", + isAdmin: false); + + Assert.Equal("Updated note", result.Text); + Assert.Equal("user-author", result.AuthorId); + Assert.Equal("Alice Dispatcher", result.AuthorName); + } + + [Fact] + public async Task UpdateComment_Admin_CanEditOthers() + { + var (_, service, comment) = await SeedEditableCommentAsync(); + + var result = await service.UpdateCommentAsync( + 1, + comment.Id, + new WorkOrderCommentCreateDto { Text = "Admin edit" }, + "admin-user", + isAdmin: true); + + Assert.Equal("Admin edit", result.Text); + Assert.Equal("user-author", result.AuthorId); + } + + [Fact] + public async Task UpdateComment_NonAuthorNonAdmin_ThrowsForbidden() + { + var (_, service, comment) = await SeedEditableCommentAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UpdateCommentAsync( + 1, + comment.Id, + new WorkOrderCommentCreateDto { Text = "Nope" }, + "other-user", + isAdmin: false)); + + 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(() => + service.UpdateCommentAsync( + 1, + comment.Id, + new WorkOrderCommentCreateDto { Text = "Nope" }, + "user-author", + isAdmin: false)); + + Assert.Equal("NotEditable", ex.Code); + } + + [Fact] + public async Task UpdateComment_ReadOnlyWorkOrder_Throws() + { + var (_, service, comment) = await SeedEditableCommentAsync(LifecycleStatus.Canceled); + + var ex = await Assert.ThrowsAsync(() => + service.UpdateCommentAsync( + 1, + comment.Id, + new WorkOrderCommentCreateDto { Text = "Nope" }, + "user-author", + isAdmin: false)); + + 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(() => + service.UpdateCommentAsync( + 2, + comment.Id, + new WorkOrderCommentCreateDto { Text = "Nope" }, + "user-author", + isAdmin: false)); + + Assert.Equal("NotFound", ex.Code); + } } public class WorkOrderMediaServiceTests