From 9b97b7b57899b0c1ef5c6c9f85545d654dae862d Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 20 May 2026 17:24:09 -0400 Subject: [PATCH] Fix proposal workflow: scoped My Proposals, idempotent state transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Mine filter to ProposalFilterRequest so My Proposals page shows only the current user's submissions regardless of role. Create proposals directly as InReview (skip Draft) since form submission is the review request. Make Approve, MarkSent, and Revise idempotent — repeat calls return current state instead of throwing. Wrap line item audit logging in try/catch so audit failures don't mask successful saves. --- .../DTOs/ProposalDtos.cs | 1 + .../Services/LineItemService.cs | 12 ++++++++++-- .../Services/ProposalService.cs | 18 ++++++++++++++++-- web/src/lib/api/proposals.ts | 2 ++ .../pages/proposals/list/ProposalListPage.tsx | 3 ++- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs index 27f1aa6..4ac926d 100644 --- a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs @@ -67,6 +67,7 @@ public record ProposalFilterRequest( DateTime? FromDate, DateTime? ToDate, string? Search, + bool Mine = false, int Page = 1, int PageSize = 25 ); diff --git a/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs b/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs index cc2e5bf..e3f53d2 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs @@ -54,7 +54,11 @@ public class LineItemService : ILineItemService _db.LineItems.Add(lineItem); await _db.SaveChangesAsync(ct); - await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct); + try + { + await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct); + } + catch { /* audit failure should not roll back a successful save */ } return MapToResponse(lineItem); } @@ -97,7 +101,11 @@ public class LineItemService : ILineItemService await _db.SaveChangesAsync(ct); - await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct); + try + { + await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct); + } + catch { /* audit failure should not roll back a successful save */ } return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList(); } diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs index fa7ff4a..10ec344 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -43,7 +43,7 @@ public class ProposalService : IProposalService ScopeOfWork = request.ScopeOfWork, ServiceCategory = request.ServiceCategory, Priority = request.Priority, - Status = ProposalStatus.Draft, + Status = ProposalStatus.InReview, Notes = request.Notes ?? string.Empty, SubmittedById = _currentUser.UserId, SubmittedAt = now, @@ -79,7 +79,7 @@ public class ProposalService : IProposalService .Include(p => p.AssignedAdmin) .AsQueryable(); - if (_currentUser.Role == UserRole.Dispatcher) + if (filter.Mine) { query = query.Where(p => p.SubmittedById == _currentUser.UserId); } @@ -166,6 +166,9 @@ public class ProposalService : IProposalService .FirstOrDefaultAsync(p => p.Id == id, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); + if (proposal.Status == ProposalStatus.Approved) + return MapToResponse(proposal); + if (proposal.Status != ProposalStatus.InReview) throw new InvalidOperationException("Only proposals in review can be approved"); @@ -189,6 +192,9 @@ public class ProposalService : IProposalService var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); + if (proposal.Status == ProposalStatus.Sent) + return MapToResponse(proposal); + if (proposal.Status != ProposalStatus.Approved) throw new InvalidOperationException("Only approved proposals can be marked as sent"); @@ -211,6 +217,14 @@ public class ProposalService : IProposalService .FirstOrDefaultAsync(p => p.Id == id, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); + if (proposal.Status == ProposalStatus.Revised) + { + var existingRevision = await _db.Proposals + .FirstOrDefaultAsync(p => p.ParentProposalId == proposal.Id, ct); + if (existingRevision != null) + return MapToResponse(existingRevision); + } + if (proposal.Status != ProposalStatus.Sent) throw new InvalidOperationException("Only sent proposals can be revised"); diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts index 09fdbb9..6afe5b5 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/lib/api/proposals.ts @@ -67,6 +67,7 @@ export interface ProposalFilters { priority?: string; fromDate?: string; toDate?: string; + mine?: boolean; } export const proposalsApi = { @@ -83,6 +84,7 @@ export const proposalsApi = { if (filters.status) params.append('status', filters.status); if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory); if (filters.priority) params.append('priority', filters.priority); + if (filters.mine) params.append('mine', 'true'); const res = await apiClient.get(`/proposals?${params.toString()}`); return res.data; diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index a2197d9..829a66a 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -40,6 +40,7 @@ function formatDate(iso: string): string { export default function ProposalListPage() { const navigate = useNavigate(); + const mineParams = { mine: true }; const { rows, search, @@ -51,7 +52,7 @@ export default function ProposalListPage() { totalCount, loading, err, - } = usePaginatedList(proposalsApi.getAll); + } = usePaginatedList(proposalsApi.getAll, mineParams); return (