feat(api): optimistic concurrency on the proposal aggregate + atomic audit staging

Phase 6a of the SHOC-alignment plan (ADR 0004, wire contract extracted
verbatim from shoc-backend PRs #10/#13-#18).

Concurrency (SHOC double-guard, Postgres port):
- long Version on Proposal + LineItem, IsConcurrencyToken, additive
  migration AddProposalLineItemVersion (DEFAULT 1; Up/Down inspected —
  no drift, exactly two AddColumn/DropColumn).
- Tokens are opaque base64 strings on the wire (RowVersionCodec:
  8-byte big-endian long), rowVersion on responses, proposalVersion on
  guarded requests. Missing -> 422 ProposalVersionRequired; malformed
  -> 422 InvalidRowVersion (BusinessRuleException carrier).
- Guarded: update, approve, return-to-review, send, revise, and bulk
  line-item update (proposal-level token — bulk replaces the item set
  wholesale, so per-item tokens are meaningless; deviation from the
  plan documented). Creates/deletes unguarded per SHOC precedent but
  bump the aggregate version.
- Conflict -> 409 { message, currentState } (SHOC envelope, reloaded
  row embedded); bare DbUpdateConcurrencyException -> 409
  { status, message, code } fallback. Both non-ProblemDetails,
  emitted by GlobalExceptionHandler.

Audit atomicity (stage-then-single-SaveChanges):
- IAuditService.Stage adds to the shared context without saving;
  every proposal/line-item mutation stages before its own single
  SaveChangesAsync, so mutation + audit commit or fail together.
  LogAsync (self-saving) remains for standalone events (downloads,
  role changes, delivery).
This commit is contained in:
Adam Moussa 2026-07-13 20:48:28 -04:00
parent 6acfdabc8c
commit 85bca54e08
No known key found for this signature in database
19 changed files with 948 additions and 111 deletions

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using ProposalSystem.Application.DTOs; using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces; using ProposalSystem.Application.Interfaces;
@ -74,33 +75,45 @@ public class ProposalsController : ControllerBase
[ProducesResponseType(typeof(ProposalResponse), 200)] [ProducesResponseType(typeof(ProposalResponse), 200)]
[ProducesResponseType(400)] [ProducesResponseType(400)]
[ProducesResponseType(404)] [ProducesResponseType(404)]
public async Task<ActionResult<ProposalResponse>> Approve(Guid id, CancellationToken ct) public async Task<ActionResult<ProposalResponse>> 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); return Ok(result);
} }
[HttpPost("{id:guid}/return-to-review")] [HttpPost("{id:guid}/return-to-review")]
[Authorize(Roles = "admins,sysadmins")] [Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> ReturnToReview(Guid id, CancellationToken ct) public async Task<ActionResult<ProposalResponse>> 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); return Ok(result);
} }
[HttpPost("{id:guid}/send")] [HttpPost("{id:guid}/send")]
[Authorize(Roles = "admins,sysadmins")] [Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct) public async Task<ActionResult<ProposalResponse>> 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); return Ok(result);
} }
[HttpPost("{id:guid}/revise")] [HttpPost("{id:guid}/revise")]
[Authorize(Roles = "admins,sysadmins")] [Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> Revise(Guid id, CancellationToken ct) public async Task<ActionResult<ProposalResponse>> 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); return Ok(result);
} }

View file

@ -2,6 +2,7 @@ using System.Net;
using System.Text.Json; using System.Text.Json;
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProposalSystem.Application.Common; using ProposalSystem.Application.Common;
namespace ProposalSystem.Api.Middleware; namespace ProposalSystem.Api.Middleware;
@ -39,8 +40,46 @@ public class GlobalExceptionHandler : IMiddleware
return problem; return problem;
} }
private static readonly JsonSerializerOptions CamelCase = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private async Task HandleExceptionAsync(HttpContext context, Exception exception) 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 // Every response carries a machine-readable "code" extension (SHOC
// error-code vocabulary convention) so clients branch on codes, not // error-code vocabulary convention) so clients branch on codes, not
// on human-readable text. // on human-readable text.

View file

@ -0,0 +1,17 @@
namespace ProposalSystem.Application.Common;
/// <summary>
/// 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 }).
/// </summary>
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;
}
}

View file

@ -0,0 +1,31 @@
using System.Buffers.Binary;
namespace ProposalSystem.Application.Common;
/// <summary>
/// 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.
/// </summary>
public static class RowVersionCodec
{
public static string Encode(long version)
{
Span<byte> 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<byte> buffer = stackalloc byte[8];
if (!Convert.TryFromBase64String(token, buffer, out var written) || written != 8)
return false;
version = BinaryPrimitives.ReadInt64BigEndian(buffer);
return true;
}
}

View file

@ -14,7 +14,8 @@ public record LineItemResponse(
int SortOrder, int SortOrder,
LineItemSource Source, LineItemSource Source,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt DateTime UpdatedAt,
string RowVersion
); );
public record CreateLineItemRequest( public record CreateLineItemRequest(
@ -29,7 +30,8 @@ public record CreateLineItemRequest(
); );
public record BulkUpdateLineItemsRequest( public record BulkUpdateLineItemsRequest(
List<UpdateLineItemEntry> LineItems List<UpdateLineItemEntry> LineItems,
string? ProposalVersion = null
); );
public record UpdateLineItemEntry( public record UpdateLineItemEntry(

View file

@ -18,9 +18,14 @@ public record UpdateProposalRequest(
string? Notes, string? Notes,
string? PoNumber, string? PoNumber,
string? WorkOrderNumber, string? WorkOrderNumber,
Guid? AssignedAdminId Guid? AssignedAdminId,
string? ProposalVersion = null
); );
/// <summary>Body for state-transition endpoints (approve, return-to-review,
/// mark-sent, revise): the expected proposal version token.</summary>
public record ProposalVersionRequest(string? ProposalVersion);
public record ProposalResponse( public record ProposalResponse(
Guid Id, Guid Id,
string ProposalNumber, string ProposalNumber,
@ -46,7 +51,8 @@ public record ProposalResponse(
int CurrentRevision, int CurrentRevision,
Guid? ParentProposalId, Guid? ParentProposalId,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt DateTime UpdatedAt,
string RowVersion
); );
public record ProposalListResponse( public record ProposalListResponse(
@ -60,7 +66,8 @@ public record ProposalListResponse(
decimal TotalBidAmount, decimal TotalBidAmount,
DateTime SubmittedAt, DateTime SubmittedAt,
string? SubmittedByName, string? SubmittedByName,
string? AssignedAdminName string? AssignedAdminName,
string RowVersion
); );
public record ProposalFilterRequest( public record ProposalFilterRequest(

View file

@ -4,5 +4,12 @@ namespace ProposalSystem.Application.Interfaces;
public interface IAuditService public interface IAuditService
{ {
/// <summary>Writes the audit entry in its own SaveChanges. For standalone
/// events (downloads, role changes) where no other write is in flight.</summary>
Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default); Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default);
/// <summary>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.</summary>
void Stage(AuditAction action, Guid? proposalId, string? details = null);
} }

View file

@ -8,10 +8,10 @@ public interface IProposalService
Task<ProposalResponse?> GetByIdAsync(Guid id, CancellationToken ct = default); Task<ProposalResponse?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default); Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default); Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default); Task<ProposalResponse> ApproveAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default); Task<ProposalResponse> ReturnToReviewAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default); Task<ProposalResponse> MarkSentAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default); Task<ProposalResponse> ReviseAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default); Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default); Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default);
Task<ProposalStatsResponse> GetStatsAsync(CancellationToken ct = default); Task<ProposalStatsResponse> GetStatsAsync(CancellationToken ct = default);

View file

@ -29,6 +29,10 @@ public class LineItem
public LineItemSource Source { get; set; } public LineItemSource Source { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
/// <summary>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.</summary>
public long Version { get; set; } = 1;
public Proposal? Proposal { get; set; } public Proposal? Proposal { get; set; }
} }

View file

@ -52,6 +52,8 @@ public class Proposal
public Guid? ParentProposalId { get; set; } public Guid? ParentProposalId { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
/// <summary>Optimistic-concurrency token; bumps on every aggregate mutation (including line-item changes).</summary>
public long Version { get; set; } = 1;
public User? SubmittedBy { get; set; } public User? SubmittedBy { get; set; }
public User? AssignedAdmin { get; set; } public User? AssignedAdmin { get; set; }

View file

@ -0,0 +1,564 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Details")
.HasColumnType("jsonb");
b.Property<string>("IpAddress")
.HasColumnType("text");
b.Property<Guid?>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Addresses")
.HasColumnType("jsonb");
b.Property<string>("ContactEmail")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Customers");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("GeneratedById")
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PricingMode")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("Unit")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Keywords")
.HasColumnType("text");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Unit")
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("PricingLibraryItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ApprovedById")
.HasColumnType("uuid");
b.Property<Guid?>("AssignedAdminId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentRevision")
.HasColumnType("integer");
b.Property<string>("CustomerAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentProposalId")
.HasColumnType("uuid");
b.Property<string>("PoNumber")
.HasColumnType("text");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProposalNumber")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RefinedScope")
.HasColumnType("text");
b.Property<string>("ScopeOfWork")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SubmittedById")
.HasColumnType("uuid");
b.Property<decimal>("TotalBidAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("VendorTotalCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<long>("Version")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(1L);
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("ReferencedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ReferencedById")
.HasColumnType("uuid");
b.Property<string>("ReferencedLibraryItemId")
.IsRequired()
.HasColumnType("text");
b.Property<float>("SimilarityScore")
.HasColumnType("real");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.HasIndex("ReferencedById");
b.ToTable("SimilarProposalReferences");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CognitoSub")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ExtractedData")
.HasColumnType("jsonb");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProcessingStatus")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<string>("S3Key")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalVendorCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProposalSystem.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddProposalLineItemVersion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "Version",
table: "Proposals",
type: "bigint",
nullable: false,
defaultValue: 1L);
migrationBuilder.AddColumn<long>(
name: "Version",
table: "LineItems",
type: "bigint",
nullable: false,
defaultValue: 1L);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Version",
table: "Proposals");
migrationBuilder.DropColumn(
name: "Version",
table: "LineItems");
}
}
}

View file

@ -17,7 +17,7 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.27") .HasAnnotation("ProductVersion", "8.0.28")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -162,6 +162,12 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<long>("Version")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(1L);
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProposalId"); b.HasIndex("ProposalId");
@ -293,6 +299,12 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("numeric(18,2)"); .HasColumnType("numeric(18,2)");
b.Property<long>("Version")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(1L);
b.Property<string>("WorkOrderNumber") b.Property<string>("WorkOrderNumber")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");

View file

@ -30,6 +30,7 @@ public class ProposalDbContext : DbContext
entity.Property(e => e.Status).HasConversion<string>(); entity.Property(e => e.Status).HasConversion<string>();
entity.Property(e => e.ServiceCategory).HasConversion<string>(); entity.Property(e => e.ServiceCategory).HasConversion<string>();
entity.Property(e => e.Priority).HasConversion<string>(); entity.Property(e => e.Priority).HasConversion<string>();
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.SubmittedBy).WithMany().HasForeignKey(e => e.SubmittedById).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.AssignedAdmin).WithMany().HasForeignKey(e => e.AssignedAdminId).OnDelete(DeleteBehavior.SetNull); 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.TotalPrice).HasPrecision(18, 2);
entity.Property(e => e.PricingMode).HasConversion<string>(); entity.Property(e => e.PricingMode).HasConversion<string>();
entity.Property(e => e.Source).HasConversion<string>(); entity.Property(e => e.Source).HasConversion<string>();
entity.Property(e => e.Version).IsConcurrencyToken().HasDefaultValue(1L);
entity.HasOne(e => e.Proposal).WithMany(p => p.LineItems).HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); entity.HasOne(e => e.Proposal).WithMany(p => p.LineItems).HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade);
}); });

View file

@ -17,6 +17,17 @@ public class AuditService : IAuditService
} }
public async Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default) 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. // 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; // 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 }); jsonDetails = JsonSerializer.Serialize(new { message = details });
} }
var entry = new AuditLog return new AuditLog
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ProposalId = proposalId, ProposalId = proposalId,
@ -41,8 +52,5 @@ public class AuditService : IAuditService
Timestamp = DateTime.UtcNow, Timestamp = DateTime.UtcNow,
IpAddress = _currentUser.IpAddress, IpAddress = _currentUser.IpAddress,
}; };
_db.AuditLogs.Add(entry);
await _db.SaveChangesAsync(ct);
} }
} }

View file

@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ProposalSystem.Application.Common;
using ProposalSystem.Application.DTOs; using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces; using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities; using ProposalSystem.Domain.Entities;
@ -62,25 +63,24 @@ public class LineItemService : ILineItemService
}; };
_db.LineItems.Add(lineItem); _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); _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); return MapToResponse(lineItem);
} }
@ -95,6 +95,11 @@ public class LineItemService : ILineItemService
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals"); 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); await using var transaction = await _db.Database.BeginTransactionAsync(ct);
try try
{ {
@ -126,24 +131,17 @@ public class LineItemService : ILineItemService
proposal.TotalBidAmount = newItems.Sum(li => li.TotalPrice); proposal.TotalBidAmount = newItems.Sum(li => li.TotalPrice);
proposal.UpdatedAt = now; proposal.UpdatedAt = now;
await _db.SaveChangesAsync(ct); // Fix: API-M12 — capture before/after item counts in audit trail;
await transaction.CommitAsync(ct); // 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 await SaveGuardedAsync(proposalId, ct);
{ await transaction.CommitAsync(ct);
// 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);
}
return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList(); return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList();
} }
@ -168,9 +166,11 @@ public class LineItemService : ILineItemService
} }
_db.LineItems.Remove(lineItem); _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 // Fix: API-M12 — capture deleted line item details in audit trail
var deleteAuditDetails = JsonSerializer.Serialize(new var deleteAuditDetails = JsonSerializer.Serialize(new
@ -178,9 +178,18 @@ public class LineItemService : ILineItemService
action = "delete", action = "delete",
lineItem = new { id = lineItemId, description = lineItem.Description, quantity = lineItem.Quantity, unit = lineItem.Unit, totalPrice = lineItem.TotalPrice } 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( private static LineItemResponse MapToResponse(LineItem li) => new(
li.Id, li.Id,
li.ProposalId, li.ProposalId,
@ -193,6 +202,7 @@ public class LineItemService : ILineItemService
li.SortOrder, li.SortOrder,
li.Source, li.Source,
li.CreatedAt, li.CreatedAt,
li.UpdatedAt li.UpdatedAt,
RowVersionCodec.Encode(li.Version)
); );
} }

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<ProposalResponse?> 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);
}
}

View file

@ -0,0 +1,39 @@
using ProposalSystem.Application.Common;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Domain.Entities;
namespace ProposalSystem.Infrastructure.Services;
/// <summary>Canonical Proposal → ProposalResponse mapping, shared by the
/// proposal/line-item services and the concurrency guard's currentState reload.</summary>
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)
);
}

View file

@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ProposalSystem.Application.Common;
using ProposalSystem.Application.DTOs; using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces; using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities; using ProposalSystem.Domain.Entities;
@ -71,21 +72,13 @@ public class ProposalService : IProposalService
}; };
_db.Proposals.Add(proposal); _db.Proposals.Add(proposal);
_audit.Stage(AuditAction.Submit, proposal.Id);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
_logger.LogInformation("Proposal {ProposalId} created with number {ProposalNumber} by user {UserId}", _logger.LogInformation("Proposal {ProposalId} created with number {ProposalNumber} by user {UserId}",
proposal.Id, proposalNumber, _currentUser.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 try
{ {
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct); await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
@ -173,7 +166,8 @@ public class ProposalService : IProposalService
p.TotalBidAmount, p.TotalBidAmount,
p.SubmittedAt, p.SubmittedAt,
p.SubmittedBy != null ? p.SubmittedBy.DisplayName : null, 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); .ToListAsync(ct);
@ -189,6 +183,8 @@ public class ProposalService : IProposalService
.FirstOrDefaultAsync(p => p.Id == id, ct) .FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found"); ?? throw new KeyNotFoundException($"Proposal {id} not found");
GuardProposalVersion(proposal, request.ProposalVersion);
// Fix: API-M12 — capture before/after values for audit trail // Fix: API-M12 — capture before/after values for audit trail
var changes = new Dictionary<string, object>(); var changes = new Dictionary<string, object>();
@ -223,15 +219,16 @@ public class ProposalService : IProposalService
} }
proposal.UpdatedAt = DateTime.UtcNow; 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; 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); return MapToResponse(proposal);
} }
public async Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default) public async Task<ProposalResponse> ApproveAsync(Guid id, string? proposalVersion, CancellationToken ct = default)
{ {
var proposal = await _db.Proposals var proposal = await _db.Proposals
.Include(p => p.LineItems) .Include(p => p.LineItems)
@ -259,6 +256,8 @@ public class ProposalService : IProposalService
throw new InvalidOperationException("Cannot approve proposal without priced line items"); throw new InvalidOperationException("Cannot approve proposal without priced line items");
} }
GuardProposalVersion(proposal, proposalVersion);
// Fix: API-M12 — capture before/after status for audit trail // Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status; var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.Approved; proposal.Status = ProposalStatus.Approved;
@ -267,9 +266,9 @@ public class ProposalService : IProposalService
proposal.TotalBidAmount = proposal.LineItems.Sum(li => li.TotalPrice); proposal.TotalBidAmount = proposal.LineItems.Sum(li => li.TotalPrice);
proposal.UpdatedAt = DateTime.UtcNow; proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var approveAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Approved.ToString() } }); 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}", _logger.LogInformation("Proposal {ProposalId} approved by user {UserId}, total bid {TotalBidAmount}",
id, _currentUser.UserId, proposal.TotalBidAmount); id, _currentUser.UserId, proposal.TotalBidAmount);
@ -277,7 +276,7 @@ public class ProposalService : IProposalService
return MapToResponse(proposal); return MapToResponse(proposal);
} }
public async Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default) public async Task<ProposalResponse> ReturnToReviewAsync(Guid id, string? proposalVersion, CancellationToken ct = default)
{ {
var proposal = await _db.Proposals var proposal = await _db.Proposals
.Include(p => p.SubmittedBy) .Include(p => p.SubmittedBy)
@ -291,6 +290,8 @@ public class ProposalService : IProposalService
if (proposal.Status != ProposalStatus.Approved) if (proposal.Status != ProposalStatus.Approved)
throw new InvalidOperationException("Only approved proposals can be returned to review"); throw new InvalidOperationException("Only approved proposals can be returned to review");
GuardProposalVersion(proposal, proposalVersion);
// Fix: API-M12 — capture before/after status for audit trail // Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status; var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.InReview; proposal.Status = ProposalStatus.InReview;
@ -298,14 +299,14 @@ public class ProposalService : IProposalService
proposal.ApprovedAt = null; proposal.ApprovedAt = null;
proposal.UpdatedAt = DateTime.UtcNow; proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var returnAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.InReview.ToString() } }); 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); return MapToResponse(proposal);
} }
public async Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default) public async Task<ProposalResponse> MarkSentAsync(Guid id, string? proposalVersion, CancellationToken ct = default)
{ {
var proposal = await _db.Proposals var proposal = await _db.Proposals
.Include(p => p.SubmittedBy) .Include(p => p.SubmittedBy)
@ -326,15 +327,17 @@ public class ProposalService : IProposalService
throw new InvalidOperationException("Only approved proposals can be marked as sent"); throw new InvalidOperationException("Only approved proposals can be marked as sent");
} }
GuardProposalVersion(proposal, proposalVersion);
// Fix: API-M12 — capture before/after status for audit trail // Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status; var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.Sent; proposal.Status = ProposalStatus.Sent;
proposal.SentAt = DateTime.UtcNow; proposal.SentAt = DateTime.UtcNow;
proposal.UpdatedAt = DateTime.UtcNow; proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var sentAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Sent.ToString() } }); 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); _logger.LogInformation("Proposal {ProposalId} marked as sent by user {UserId}", id, _currentUser.UserId);
@ -424,7 +427,7 @@ public class ProposalService : IProposalService
} }
} }
public async Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default) public async Task<ProposalResponse> ReviseAsync(Guid id, string? proposalVersion, CancellationToken ct = default)
{ {
var proposal = await _db.Proposals var proposal = await _db.Proposals
.Include(p => p.LineItems) .Include(p => p.LineItems)
@ -450,6 +453,8 @@ public class ProposalService : IProposalService
throw new InvalidOperationException("Only sent proposals can be revised"); throw new InvalidOperationException("Only sent proposals can be revised");
} }
GuardProposalVersion(proposal, proposalVersion);
var revision = new Proposal var revision = new Proposal
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@ -499,14 +504,13 @@ public class ProposalService : IProposalService
proposal.UpdatedAt = DateTime.UtcNow; proposal.UpdatedAt = DateTime.UtcNow;
_db.Proposals.Add(revision); _db.Proposals.Add(revision);
await _db.SaveChangesAsync(ct);
var reviseAuditDetails = JsonSerializer.Serialize(new var reviseAuditDetails = JsonSerializer.Serialize(new
{ {
status = new { old = previousReviseStatus.ToString(), @new = ProposalStatus.Revised.ToString() }, status = new { old = previousReviseStatus.ToString(), @new = ProposalStatus.Revised.ToString() },
message = $"Revised from {proposal.Id}" 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}", _logger.LogInformation("Proposal {ProposalId} revised to {RevisionId} (revision {RevisionNumber}) by user {UserId}",
id, revision.Id, revision.CurrentRevision, _currentUser.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); : new ProposalStatsResponse(counts.Total, counts.InReview, counts.Approved, counts.Sent);
} }
private static ProposalResponse MapToResponse(Proposal p) => new( private void GuardProposalVersion(Proposal proposal, string? proposalVersion) =>
p.Id, ProposalConcurrencyGuard.Guard(_db, proposal, proposalVersion);
p.ProposalNumber,
p.WorkOrderNumber, private Task SaveGuardedAsync(Guid proposalId, CancellationToken ct) =>
p.PoNumber, ProposalConcurrencyGuard.SaveAsync(_db, proposalId, ct);
p.CustomerName,
p.CustomerAddress, private static ProposalResponse MapToResponse(Proposal p) => ProposalMapper.ToResponse(p);
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
);
} }