diff --git a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs index dcac93f..b6cf978 100644 --- a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs +++ b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using ProposalSystem.Application.DTOs; using ProposalSystem.Application.Interfaces; @@ -74,33 +75,45 @@ public class ProposalsController : ControllerBase [ProducesResponseType(typeof(ProposalResponse), 200)] [ProducesResponseType(400)] [ProducesResponseType(404)] - public async Task> Approve(Guid id, CancellationToken ct) + public async Task> Approve( + Guid id, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request, + CancellationToken ct) { - var result = await _proposalService.ApproveAsync(id, ct); + var result = await _proposalService.ApproveAsync(id, request?.ProposalVersion, ct); return Ok(result); } [HttpPost("{id:guid}/return-to-review")] [Authorize(Roles = "admins,sysadmins")] - public async Task> ReturnToReview(Guid id, CancellationToken ct) + public async Task> ReturnToReview( + Guid id, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request, + CancellationToken ct) { - var result = await _proposalService.ReturnToReviewAsync(id, ct); + var result = await _proposalService.ReturnToReviewAsync(id, request?.ProposalVersion, ct); return Ok(result); } [HttpPost("{id:guid}/send")] [Authorize(Roles = "admins,sysadmins")] - public async Task> MarkSent(Guid id, CancellationToken ct) + public async Task> MarkSent( + Guid id, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request, + CancellationToken ct) { - var result = await _proposalService.MarkSentAsync(id, ct); + var result = await _proposalService.MarkSentAsync(id, request?.ProposalVersion, ct); return Ok(result); } [HttpPost("{id:guid}/revise")] [Authorize(Roles = "admins,sysadmins")] - public async Task> Revise(Guid id, CancellationToken ct) + public async Task> Revise( + Guid id, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request, + CancellationToken ct) { - var result = await _proposalService.ReviseAsync(id, ct); + var result = await _proposalService.ReviseAsync(id, request?.ProposalVersion, ct); return Ok(result); } diff --git a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs index af6206f..d8e1fa2 100644 --- a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs +++ b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.Json; using FluentValidation; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using ProposalSystem.Application.Common; namespace ProposalSystem.Api.Middleware; @@ -39,8 +40,46 @@ public class GlobalExceptionHandler : IMiddleware return problem; } + private static readonly JsonSerializerOptions CamelCase = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + private async Task HandleExceptionAsync(HttpContext context, Exception exception) { + // Concurrency conflicts use SHOC's envelopes verbatim (ADR 0004), NOT + // ProblemDetails: the guarded path embeds the reloaded currentState for + // immediate client refresh; the bare-DbUpdateConcurrencyException shape + // is the safety net for any unguarded write. + if (exception is ProposalConcurrencyException concurrencyEx) + { + _logger.LogWarning("Concurrency conflict on {Method} {Path}", + context.Request.Method, context.Request.Path); + context.Response.StatusCode = StatusCodes.Status409Conflict; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(new + { + message = concurrencyEx.Message, + currentState = concurrencyEx.CurrentState, + }, CamelCase)); + return; + } + + if (exception is DbUpdateConcurrencyException) + { + _logger.LogWarning("Unguarded concurrency conflict on {Method} {Path}", + context.Request.Method, context.Request.Path); + context.Response.StatusCode = StatusCodes.Status409Conflict; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(new + { + status = "Conflict", + message = "The record was modified by another user. Refresh and retry.", + code = 409, + }, CamelCase)); + return; + } + // Every response carries a machine-readable "code" extension (SHOC // error-code vocabulary convention) so clients branch on codes, not // on human-readable text. diff --git a/api/src/ProposalSystem.Application/Common/ProposalConcurrencyException.cs b/api/src/ProposalSystem.Application/Common/ProposalConcurrencyException.cs new file mode 100644 index 0000000..9e1a637 --- /dev/null +++ b/api/src/ProposalSystem.Application/Common/ProposalConcurrencyException.cs @@ -0,0 +1,17 @@ +namespace ProposalSystem.Application.Common; + +/// +/// Optimistic-concurrency conflict on the proposal aggregate. Carries the +/// freshly reloaded current state so the 409 body can embed it for immediate +/// client refresh (SHOC contract: 409 { message, currentState }). +/// +public class ProposalConcurrencyException : Exception +{ + public object? CurrentState { get; } + + public ProposalConcurrencyException(object? currentState) + : base("The record was modified by another user. Refresh and retry.") + { + CurrentState = currentState; + } +} diff --git a/api/src/ProposalSystem.Application/Common/RowVersionCodec.cs b/api/src/ProposalSystem.Application/Common/RowVersionCodec.cs new file mode 100644 index 0000000..b9df3b3 --- /dev/null +++ b/api/src/ProposalSystem.Application/Common/RowVersionCodec.cs @@ -0,0 +1,31 @@ +using System.Buffers.Binary; + +namespace ProposalSystem.Application.Common; + +/// +/// Encodes the integer concurrency version as an opaque base64 token +/// (8 bytes, big-endian), matching SHOC's rowversion wire contract: +/// clients receive "rowVersion" strings and echo them back untouched. +/// +public static class RowVersionCodec +{ + public static string Encode(long version) + { + Span buffer = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(buffer, version); + return Convert.ToBase64String(buffer); + } + + public static bool TryDecode(string? token, out long version) + { + version = 0; + if (string.IsNullOrEmpty(token)) return false; + + Span buffer = stackalloc byte[8]; + if (!Convert.TryFromBase64String(token, buffer, out var written) || written != 8) + return false; + + version = BinaryPrimitives.ReadInt64BigEndian(buffer); + return true; + } +} diff --git a/api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs b/api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs index 33fd798..588ff91 100644 --- a/api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/LineItemDtos.cs @@ -14,7 +14,8 @@ public record LineItemResponse( int SortOrder, LineItemSource Source, DateTime CreatedAt, - DateTime UpdatedAt + DateTime UpdatedAt, + string RowVersion ); public record CreateLineItemRequest( @@ -29,7 +30,8 @@ public record CreateLineItemRequest( ); public record BulkUpdateLineItemsRequest( - List LineItems + List LineItems, + string? ProposalVersion = null ); public record UpdateLineItemEntry( diff --git a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs index ff72e71..baf2511 100644 --- a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs @@ -18,9 +18,14 @@ public record UpdateProposalRequest( string? Notes, string? PoNumber, string? WorkOrderNumber, - Guid? AssignedAdminId + Guid? AssignedAdminId, + string? ProposalVersion = null ); +/// Body for state-transition endpoints (approve, return-to-review, +/// mark-sent, revise): the expected proposal version token. +public record ProposalVersionRequest(string? ProposalVersion); + public record ProposalResponse( Guid Id, string ProposalNumber, @@ -46,7 +51,8 @@ public record ProposalResponse( int CurrentRevision, Guid? ParentProposalId, DateTime CreatedAt, - DateTime UpdatedAt + DateTime UpdatedAt, + string RowVersion ); public record ProposalListResponse( @@ -60,7 +66,8 @@ public record ProposalListResponse( decimal TotalBidAmount, DateTime SubmittedAt, string? SubmittedByName, - string? AssignedAdminName + string? AssignedAdminName, + string RowVersion ); public record ProposalFilterRequest( diff --git a/api/src/ProposalSystem.Application/Interfaces/IAuditService.cs b/api/src/ProposalSystem.Application/Interfaces/IAuditService.cs index 8551944..4a1f095 100644 --- a/api/src/ProposalSystem.Application/Interfaces/IAuditService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/IAuditService.cs @@ -4,5 +4,12 @@ namespace ProposalSystem.Application.Interfaces; public interface IAuditService { + /// Writes the audit entry in its own SaveChanges. For standalone + /// events (downloads, role changes) where no other write is in flight. Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default); + + /// Stages the audit entry on the shared DbContext WITHOUT saving. + /// Mutation flows call this before their own SaveChangesAsync so the domain + /// change and its audit row commit atomically or not at all. + void Stage(AuditAction action, Guid? proposalId, string? details = null); } diff --git a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs index fbcfef6..1abea91 100644 --- a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs @@ -8,10 +8,10 @@ public interface IProposalService Task GetByIdAsync(Guid id, CancellationToken ct = default); Task> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default); Task UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default); - Task ApproveAsync(Guid id, CancellationToken ct = default); - Task ReturnToReviewAsync(Guid id, CancellationToken ct = default); - Task MarkSentAsync(Guid id, CancellationToken ct = default); - Task ReviseAsync(Guid id, CancellationToken ct = default); + Task ApproveAsync(Guid id, string? proposalVersion, CancellationToken ct = default); + Task ReturnToReviewAsync(Guid id, string? proposalVersion, CancellationToken ct = default); + Task MarkSentAsync(Guid id, string? proposalVersion, CancellationToken ct = default); + Task ReviseAsync(Guid id, string? proposalVersion, CancellationToken ct = default); Task> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default); Task> GetAuditTrailAsync(Guid id, CancellationToken ct = default); Task GetStatsAsync(CancellationToken ct = default); diff --git a/api/src/ProposalSystem.Domain/Entities/LineItem.cs b/api/src/ProposalSystem.Domain/Entities/LineItem.cs index e876e18..db98009 100644 --- a/api/src/ProposalSystem.Domain/Entities/LineItem.cs +++ b/api/src/ProposalSystem.Domain/Entities/LineItem.cs @@ -29,6 +29,10 @@ public class LineItem public LineItemSource Source { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + /// Optimistic-concurrency token. Bulk updates are guarded at the + /// proposal level (rows are replaced wholesale); this exists for per-item + /// mutations and client cache keys. + public long Version { get; set; } = 1; public Proposal? Proposal { get; set; } } diff --git a/api/src/ProposalSystem.Domain/Entities/Proposal.cs b/api/src/ProposalSystem.Domain/Entities/Proposal.cs index 4d7d9d3..ab03f92 100644 --- a/api/src/ProposalSystem.Domain/Entities/Proposal.cs +++ b/api/src/ProposalSystem.Domain/Entities/Proposal.cs @@ -52,6 +52,8 @@ public class Proposal public Guid? ParentProposalId { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + /// Optimistic-concurrency token; bumps on every aggregate mutation (including line-item changes). + public long Version { get; set; } = 1; public User? SubmittedBy { get; set; } public User? AssignedAdmin { get; set; } diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260714003834_AddProposalLineItemVersion.Designer.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260714003834_AddProposalLineItemVersion.Designer.cs new file mode 100644 index 0000000..17a00e5 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260714003834_AddProposalLineItemVersion.Designer.cs @@ -0,0 +1,564 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ProposalSystem.Infrastructure.Data; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ProposalDbContext))] + [Migration("20260714003834_AddProposalLineItemVersion")] + partial class AddProposalLineItemVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.28") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .HasColumnType("jsonb"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Addresses") + .HasColumnType("jsonb"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedById") + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedById"); + + b.HasIndex("ProposalId"); + + b.ToTable("GeneratedPdfs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("PricingMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("LineItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingLibraryItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedById") + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentRevision") + .HasColumnType("integer"); + + b.Property("CustomerAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentProposalId") + .HasColumnType("uuid"); + + b.Property("PoNumber") + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefinedScope") + .HasColumnType("text"); + + b.Property("ScopeOfWork") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("TotalBidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorTotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("WorkOrderNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedById"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("ParentProposalId"); + + b.HasIndex("ProposalNumber") + .IsUnique(); + + b.HasIndex("SubmittedById"); + + b.ToTable("Proposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReferencedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencedById") + .HasColumnType("uuid"); + + b.Property("ReferencedLibraryItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SimilarityScore") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("ReferencedById"); + + b.ToTable("SimilarProposalReferences"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CognitoSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CognitoSub") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExtractedData") + .HasColumnType("jsonb"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalVendorCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("VendorProposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId"); + + b.HasOne("ProposalSystem.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy") + .WithMany() + .HasForeignKey("GeneratedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GeneratedBy"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("LineItems") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy") + .WithMany() + .HasForeignKey("ApprovedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal") + .WithMany() + .HasForeignKey("ParentProposalId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy") + .WithMany() + .HasForeignKey("SubmittedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApprovedBy"); + + b.Navigation("AssignedAdmin"); + + b.Navigation("ParentProposal"); + + b.Navigation("SubmittedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy") + .WithMany() + .HasForeignKey("ReferencedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("ReferencedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("VendorProposals") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Navigation("LineItems"); + + b.Navigation("VendorProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260714003834_AddProposalLineItemVersion.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260714003834_AddProposalLineItemVersion.cs new file mode 100644 index 0000000..2e38f96 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260714003834_AddProposalLineItemVersion.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + /// + public partial class AddProposalLineItemVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Version", + table: "Proposals", + type: "bigint", + nullable: false, + defaultValue: 1L); + + migrationBuilder.AddColumn( + name: "Version", + table: "LineItems", + type: "bigint", + nullable: false, + defaultValue: 1L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Version", + table: "Proposals"); + + migrationBuilder.DropColumn( + name: "Version", + table: "LineItems"); + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs index 0aeb2a3..d1eed98 100644 --- a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace ProposalSystem.Infrastructure.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.27") + .HasAnnotation("ProductVersion", "8.0.28") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -162,6 +162,12 @@ namespace ProposalSystem.Infrastructure.Data.Migrations b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + b.HasKey("Id"); b.HasIndex("ProposalId"); @@ -293,6 +299,12 @@ namespace ProposalSystem.Infrastructure.Data.Migrations .HasPrecision(18, 2) .HasColumnType("numeric(18,2)"); + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + b.Property("WorkOrderNumber") .IsRequired() .HasColumnType("text"); diff --git a/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs index bc7aaf9..f10b795 100644 --- a/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs +++ b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs @@ -30,6 +30,7 @@ public class ProposalDbContext : DbContext entity.Property(e => e.Status).HasConversion(); entity.Property(e => e.ServiceCategory).HasConversion(); entity.Property(e => e.Priority).HasConversion(); + entity.Property(e => e.Version).IsConcurrencyToken().HasDefaultValue(1L); entity.HasOne(e => e.SubmittedBy).WithMany().HasForeignKey(e => e.SubmittedById).OnDelete(DeleteBehavior.Restrict); entity.HasOne(e => e.AssignedAdmin).WithMany().HasForeignKey(e => e.AssignedAdminId).OnDelete(DeleteBehavior.SetNull); @@ -45,6 +46,7 @@ public class ProposalDbContext : DbContext entity.Property(e => e.TotalPrice).HasPrecision(18, 2); entity.Property(e => e.PricingMode).HasConversion(); entity.Property(e => e.Source).HasConversion(); + entity.Property(e => e.Version).IsConcurrencyToken().HasDefaultValue(1L); entity.HasOne(e => e.Proposal).WithMany(p => p.LineItems).HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); }); diff --git a/api/src/ProposalSystem.Infrastructure/Services/AuditService.cs b/api/src/ProposalSystem.Infrastructure/Services/AuditService.cs index b00dc02..ba7d25a 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/AuditService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/AuditService.cs @@ -17,6 +17,17 @@ public class AuditService : IAuditService } public async Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default) + { + Stage(action, proposalId, details); + await _db.SaveChangesAsync(ct); + } + + public void Stage(AuditAction action, Guid? proposalId, string? details = null) + { + _db.AuditLogs.Add(BuildEntry(action, proposalId, details)); + } + + private AuditLog BuildEntry(AuditAction action, Guid? proposalId, string? details) { // Fix: API-M12 — accept pre-serialized JSON from callers that provide structured audit data. // If the details string is already valid JSON (starts with '{'), use it directly; @@ -31,7 +42,7 @@ public class AuditService : IAuditService jsonDetails = JsonSerializer.Serialize(new { message = details }); } - var entry = new AuditLog + return new AuditLog { Id = Guid.NewGuid(), ProposalId = proposalId, @@ -41,8 +52,5 @@ public class AuditService : IAuditService Timestamp = DateTime.UtcNow, IpAddress = _currentUser.IpAddress, }; - - _db.AuditLogs.Add(entry); - await _db.SaveChangesAsync(ct); } } diff --git a/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs b/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs index b2f4a9d..ebbe3bc 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/LineItemService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using ProposalSystem.Application.Common; using ProposalSystem.Application.DTOs; using ProposalSystem.Application.Interfaces; using ProposalSystem.Domain.Entities; @@ -62,25 +63,24 @@ public class LineItemService : ILineItemService }; _db.LineItems.Add(lineItem); - await _db.SaveChangesAsync(ct); + + // Appends are not token-guarded (SHOC precedent: creates are unguarded), + // but they still bump the aggregate version so concurrent guarded edits + // observe the change and clients refresh their token. + proposal.Version++; + proposal.UpdatedAt = now; + + // Fix: API-M12 — capture line item details in audit trail + var addAuditDetails = JsonSerializer.Serialize(new + { + action = "add", + lineItem = new { description = request.Description, quantity = request.Quantity, unit = request.Unit, totalPrice = request.TotalPrice } + }); + _audit.Stage(AuditAction.EditLineItem, proposalId, addAuditDetails); + await SaveGuardedAsync(proposalId, ct); _logger.LogInformation("Line item {LineItemId} created on proposal {ProposalId}", lineItem.Id, proposalId); - try - { - // Fix: API-M12 — capture line item details in audit trail - var addAuditDetails = JsonSerializer.Serialize(new - { - action = "add", - lineItem = new { description = request.Description, quantity = request.Quantity, unit = request.Unit, totalPrice = request.TotalPrice } - }); - await _audit.LogAsync(AuditAction.EditLineItem, proposalId, addAuditDetails, ct); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to write audit log for line item creation on proposal {ProposalId}", proposalId); - } - return MapToResponse(lineItem); } @@ -95,6 +95,11 @@ public class LineItemService : ILineItemService throw new InvalidOperationException("Cannot modify line items on approved/sent proposals"); } + // Bulk update replaces the line-item set wholesale, so the guard is the + // PROPOSAL token: any concurrent change to the aggregate (fields or items) + // bumped it, and a stale replace would silently discard those edits. + GuardProposalVersion(proposal, request.ProposalVersion); + await using var transaction = await _db.Database.BeginTransactionAsync(ct); try { @@ -126,24 +131,17 @@ public class LineItemService : ILineItemService proposal.TotalBidAmount = newItems.Sum(li => li.TotalPrice); proposal.UpdatedAt = now; - await _db.SaveChangesAsync(ct); - await transaction.CommitAsync(ct); + // Fix: API-M12 — capture before/after item counts in audit trail; + // staged so it commits atomically with the replace. + var bulkAuditDetails = JsonSerializer.Serialize(new + { + action = "bulkUpdate", + itemCount = new { old = existing.Count, @new = newItems.Count } + }); + _audit.Stage(AuditAction.EditLineItem, proposalId, bulkAuditDetails); - try - { - // Fix: API-M12 — capture before/after item counts in audit trail - var bulkAuditDetails = JsonSerializer.Serialize(new - { - action = "bulkUpdate", - itemCount = new { old = existing.Count, @new = newItems.Count } - }); - await _audit.LogAsync(AuditAction.EditLineItem, proposalId, bulkAuditDetails, ct); - } - catch (Exception ex) - { - // Fix: API-M11 — log audit failures instead of silently swallowing - _logger.LogError(ex, "Failed to write audit log for bulk update on proposal {ProposalId}", proposalId); - } + await SaveGuardedAsync(proposalId, ct); + await transaction.CommitAsync(ct); return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList(); } @@ -168,9 +166,11 @@ public class LineItemService : ILineItemService } _db.LineItems.Remove(lineItem); - await _db.SaveChangesAsync(ct); - _logger.LogInformation("Line item {LineItemId} deleted from proposal {ProposalId}", lineItemId, proposalId); + // Deletes are not token-guarded (SHOC precedent) but bump the aggregate + // version so concurrent guarded edits conflict instead of losing the delete. + proposal.Version++; + proposal.UpdatedAt = DateTime.UtcNow; // Fix: API-M12 — capture deleted line item details in audit trail var deleteAuditDetails = JsonSerializer.Serialize(new @@ -178,9 +178,18 @@ public class LineItemService : ILineItemService action = "delete", lineItem = new { id = lineItemId, description = lineItem.Description, quantity = lineItem.Quantity, unit = lineItem.Unit, totalPrice = lineItem.TotalPrice } }); - await _audit.LogAsync(AuditAction.EditLineItem, proposalId, deleteAuditDetails, ct); + _audit.Stage(AuditAction.EditLineItem, proposalId, deleteAuditDetails); + await SaveGuardedAsync(proposalId, ct); + + _logger.LogInformation("Line item {LineItemId} deleted from proposal {ProposalId}", lineItemId, proposalId); } + private void GuardProposalVersion(Proposal proposal, string? proposalVersion) => + ProposalConcurrencyGuard.Guard(_db, proposal, proposalVersion); + + private Task SaveGuardedAsync(Guid proposalId, CancellationToken ct) => + ProposalConcurrencyGuard.SaveAsync(_db, proposalId, ct); + private static LineItemResponse MapToResponse(LineItem li) => new( li.Id, li.ProposalId, @@ -193,6 +202,7 @@ public class LineItemService : ILineItemService li.SortOrder, li.Source, li.CreatedAt, - li.UpdatedAt + li.UpdatedAt, + RowVersionCodec.Encode(li.Version) ); } diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalConcurrencyGuard.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalConcurrencyGuard.cs new file mode 100644 index 0000000..fdd9465 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalConcurrencyGuard.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Application.Common; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Infrastructure.Services; + +/// +/// SHOC double-guard for the proposal aggregate (see ADR 0004). +/// Step 1 (Guard): validate the client token against the freshly loaded row and +/// stamp it as EF's original value so the UPDATE's WHERE clause re-enforces it +/// at the database. Step 2 (SaveAsync): on a lost race, reload the current +/// state and throw the conflict exception that becomes the 409 body. +/// +internal static class ProposalConcurrencyGuard +{ + internal static void Guard(ProposalDbContext db, Proposal proposal, string? proposalVersion) + { + if (string.IsNullOrEmpty(proposalVersion)) + throw new BusinessRuleException("ProposalVersionRequired", "proposalVersion is required for this operation."); + if (!RowVersionCodec.TryDecode(proposalVersion, out var expected)) + throw new BusinessRuleException("InvalidRowVersion", "proposalVersion is not a valid version token."); + if (proposal.Version != expected) + throw new ProposalConcurrencyException(ProposalMapper.ToResponse(proposal)); + + db.Entry(proposal).Property(p => p.Version).OriginalValue = expected; + proposal.Version++; + } + + internal static async Task SaveAsync(ProposalDbContext db, Guid proposalId, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + db.ChangeTracker.Clear(); + var currentState = await LoadCurrentStateAsync(db, proposalId, ct); + throw new ProposalConcurrencyException(currentState); + } + } + + internal static async Task LoadCurrentStateAsync( + ProposalDbContext db, Guid proposalId, CancellationToken ct) + { + var current = await db.Proposals + .AsNoTracking() + .Include(p => p.SubmittedBy) + .Include(p => p.AssignedAdmin) + .Include(p => p.ApprovedBy) + .FirstOrDefaultAsync(p => p.Id == proposalId, ct); + return current is null ? null : ProposalMapper.ToResponse(current); + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalMapper.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalMapper.cs new file mode 100644 index 0000000..b34cfa0 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalMapper.cs @@ -0,0 +1,39 @@ +using ProposalSystem.Application.Common; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Domain.Entities; + +namespace ProposalSystem.Infrastructure.Services; + +/// Canonical Proposal → ProposalResponse mapping, shared by the +/// proposal/line-item services and the concurrency guard's currentState reload. +internal static class ProposalMapper +{ + internal static ProposalResponse ToResponse(Proposal p) => new( + p.Id, + p.ProposalNumber, + p.WorkOrderNumber, + p.PoNumber, + p.CustomerName, + p.CustomerAddress, + p.ScopeOfWork, + p.RefinedScope, + p.ServiceCategory, + p.Priority, + p.Status, + p.TotalBidAmount, + p.VendorTotalCost, + p.Notes, + p.SubmittedById, + p.SubmittedBy?.DisplayName, + p.SubmittedAt, + p.AssignedAdminId, + p.ApprovedById, + p.ApprovedAt, + p.SentAt, + p.CurrentRevision, + p.ParentProposalId, + p.CreatedAt, + p.UpdatedAt, + RowVersionCodec.Encode(p.Version) + ); +} diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs index a0448f7..d2202aa 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using ProposalSystem.Application.Common; using ProposalSystem.Application.DTOs; using ProposalSystem.Application.Interfaces; using ProposalSystem.Domain.Entities; @@ -71,21 +72,13 @@ public class ProposalService : IProposalService }; _db.Proposals.Add(proposal); + _audit.Stage(AuditAction.Submit, proposal.Id); await _db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); _logger.LogInformation("Proposal {ProposalId} created with number {ProposalNumber} by user {UserId}", proposal.Id, proposalNumber, _currentUser.UserId); - try - { - await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to write audit log for proposal submission {ProposalId}", proposal.Id); - } - try { await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct); @@ -173,7 +166,8 @@ public class ProposalService : IProposalService p.TotalBidAmount, p.SubmittedAt, p.SubmittedBy != null ? p.SubmittedBy.DisplayName : null, - p.AssignedAdmin != null ? p.AssignedAdmin.DisplayName : null + p.AssignedAdmin != null ? p.AssignedAdmin.DisplayName : null, + RowVersionCodec.Encode(p.Version) )) .ToListAsync(ct); @@ -189,6 +183,8 @@ public class ProposalService : IProposalService .FirstOrDefaultAsync(p => p.Id == id, ct) ?? throw new KeyNotFoundException($"Proposal {id} not found"); + GuardProposalVersion(proposal, request.ProposalVersion); + // Fix: API-M12 — capture before/after values for audit trail var changes = new Dictionary(); @@ -223,15 +219,16 @@ public class ProposalService : IProposalService } proposal.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(ct); + // Stage-then-single-SaveChanges: the audit row commits atomically with the mutation. var auditDetails = changes.Count > 0 ? JsonSerializer.Serialize(changes) : null; - await _audit.LogAsync(AuditAction.Edit, id, auditDetails, ct); + _audit.Stage(AuditAction.Edit, id, auditDetails); + await SaveGuardedAsync(id, ct); return MapToResponse(proposal); } - public async Task ApproveAsync(Guid id, CancellationToken ct = default) + public async Task ApproveAsync(Guid id, string? proposalVersion, CancellationToken ct = default) { var proposal = await _db.Proposals .Include(p => p.LineItems) @@ -259,6 +256,8 @@ public class ProposalService : IProposalService throw new InvalidOperationException("Cannot approve proposal without priced line items"); } + GuardProposalVersion(proposal, proposalVersion); + // Fix: API-M12 — capture before/after status for audit trail var previousStatus = proposal.Status; proposal.Status = ProposalStatus.Approved; @@ -267,9 +266,9 @@ public class ProposalService : IProposalService proposal.TotalBidAmount = proposal.LineItems.Sum(li => li.TotalPrice); proposal.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(ct); var approveAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Approved.ToString() } }); - await _audit.LogAsync(AuditAction.Approve, id, approveAuditDetails, ct); + _audit.Stage(AuditAction.Approve, id, approveAuditDetails); + await SaveGuardedAsync(id, ct); _logger.LogInformation("Proposal {ProposalId} approved by user {UserId}, total bid {TotalBidAmount}", id, _currentUser.UserId, proposal.TotalBidAmount); @@ -277,7 +276,7 @@ public class ProposalService : IProposalService return MapToResponse(proposal); } - public async Task ReturnToReviewAsync(Guid id, CancellationToken ct = default) + public async Task ReturnToReviewAsync(Guid id, string? proposalVersion, CancellationToken ct = default) { var proposal = await _db.Proposals .Include(p => p.SubmittedBy) @@ -291,6 +290,8 @@ public class ProposalService : IProposalService if (proposal.Status != ProposalStatus.Approved) throw new InvalidOperationException("Only approved proposals can be returned to review"); + GuardProposalVersion(proposal, proposalVersion); + // Fix: API-M12 — capture before/after status for audit trail var previousStatus = proposal.Status; proposal.Status = ProposalStatus.InReview; @@ -298,14 +299,14 @@ public class ProposalService : IProposalService proposal.ApprovedAt = null; proposal.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(ct); var returnAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.InReview.ToString() } }); - await _audit.LogAsync(AuditAction.ReturnToReview, id, returnAuditDetails, ct); + _audit.Stage(AuditAction.ReturnToReview, id, returnAuditDetails); + await SaveGuardedAsync(id, ct); return MapToResponse(proposal); } - public async Task MarkSentAsync(Guid id, CancellationToken ct = default) + public async Task MarkSentAsync(Guid id, string? proposalVersion, CancellationToken ct = default) { var proposal = await _db.Proposals .Include(p => p.SubmittedBy) @@ -326,15 +327,17 @@ public class ProposalService : IProposalService throw new InvalidOperationException("Only approved proposals can be marked as sent"); } + GuardProposalVersion(proposal, proposalVersion); + // Fix: API-M12 — capture before/after status for audit trail var previousStatus = proposal.Status; proposal.Status = ProposalStatus.Sent; proposal.SentAt = DateTime.UtcNow; proposal.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(ct); var sentAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Sent.ToString() } }); - await _audit.LogAsync(AuditAction.MarkSent, id, sentAuditDetails, ct); + _audit.Stage(AuditAction.MarkSent, id, sentAuditDetails); + await SaveGuardedAsync(id, ct); _logger.LogInformation("Proposal {ProposalId} marked as sent by user {UserId}", id, _currentUser.UserId); @@ -424,7 +427,7 @@ public class ProposalService : IProposalService } } - public async Task ReviseAsync(Guid id, CancellationToken ct = default) + public async Task ReviseAsync(Guid id, string? proposalVersion, CancellationToken ct = default) { var proposal = await _db.Proposals .Include(p => p.LineItems) @@ -450,6 +453,8 @@ public class ProposalService : IProposalService throw new InvalidOperationException("Only sent proposals can be revised"); } + GuardProposalVersion(proposal, proposalVersion); + var revision = new Proposal { Id = Guid.NewGuid(), @@ -499,14 +504,13 @@ public class ProposalService : IProposalService proposal.UpdatedAt = DateTime.UtcNow; _db.Proposals.Add(revision); - await _db.SaveChangesAsync(ct); - var reviseAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousReviseStatus.ToString(), @new = ProposalStatus.Revised.ToString() }, message = $"Revised from {proposal.Id}" }); - await _audit.LogAsync(AuditAction.CreateRevision, revision.Id, reviseAuditDetails, ct); + _audit.Stage(AuditAction.CreateRevision, revision.Id, reviseAuditDetails); + await SaveGuardedAsync(id, ct); _logger.LogInformation("Proposal {ProposalId} revised to {RevisionId} (revision {RevisionNumber}) by user {UserId}", id, revision.Id, revision.CurrentRevision, _currentUser.UserId); @@ -576,31 +580,11 @@ public class ProposalService : IProposalService : new ProposalStatsResponse(counts.Total, counts.InReview, counts.Approved, counts.Sent); } - private static ProposalResponse MapToResponse(Proposal p) => new( - p.Id, - p.ProposalNumber, - p.WorkOrderNumber, - p.PoNumber, - p.CustomerName, - p.CustomerAddress, - p.ScopeOfWork, - p.RefinedScope, - p.ServiceCategory, - p.Priority, - p.Status, - p.TotalBidAmount, - p.VendorTotalCost, - p.Notes, - p.SubmittedById, - p.SubmittedBy?.DisplayName, - p.SubmittedAt, - p.AssignedAdminId, - p.ApprovedById, - p.ApprovedAt, - p.SentAt, - p.CurrentRevision, - p.ParentProposalId, - p.CreatedAt, - p.UpdatedAt - ); + private void GuardProposalVersion(Proposal proposal, string? proposalVersion) => + ProposalConcurrencyGuard.Guard(_db, proposal, proposalVersion); + + private Task SaveGuardedAsync(Guid proposalId, CancellationToken ct) => + ProposalConcurrencyGuard.SaveAsync(_db, proposalId, ct); + + private static ProposalResponse MapToResponse(Proposal p) => ProposalMapper.ToResponse(p); }