mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 11:38:56 +00:00
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:
parent
6acfdabc8c
commit
85bca54e08
19 changed files with 948 additions and 111 deletions
|
|
@ -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<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);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/return-to-review")]
|
||||
[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);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/send")]
|
||||
[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);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/revise")]
|
||||
[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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
31
api/src/ProposalSystem.Application/Common/RowVersionCodec.cs
Normal file
31
api/src/ProposalSystem.Application/Common/RowVersionCodec.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UpdateLineItemEntry> LineItems
|
||||
List<UpdateLineItemEntry> LineItems,
|
||||
string? ProposalVersion = null
|
||||
);
|
||||
|
||||
public record UpdateLineItemEntry(
|
||||
|
|
|
|||
|
|
@ -18,9 +18,14 @@ public record UpdateProposalRequest(
|
|||
string? Notes,
|
||||
string? PoNumber,
|
||||
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(
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -4,5 +4,12 @@ namespace ProposalSystem.Application.Interfaces;
|
|||
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ public interface IProposalService
|
|||
Task<ProposalResponse?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
|
||||
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ApproveAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ReturnToReviewAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
|
||||
Task<ProposalResponse> MarkSentAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
|
||||
Task<ProposalResponse> ReviseAsync(Guid id, string? proposalVersion, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default);
|
||||
Task<ProposalStatsResponse> GetStatsAsync(CancellationToken ct = default);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ public class LineItem
|
|||
public LineItemSource Source { get; set; }
|
||||
public DateTime CreatedAt { 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ public class Proposal
|
|||
public Guid? ParentProposalId { get; set; }
|
||||
public DateTime CreatedAt { 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? AssignedAdmin { get; set; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("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<long>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(1L);
|
||||
|
||||
b.Property<string>("WorkOrderNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class ProposalDbContext : DbContext
|
|||
entity.Property(e => e.Status).HasConversion<string>();
|
||||
entity.Property(e => e.ServiceCategory).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.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<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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, object>();
|
||||
|
||||
|
|
@ -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<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default)
|
||||
public async Task<ProposalResponse> 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<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default)
|
||||
public async Task<ProposalResponse> 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<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default)
|
||||
public async Task<ProposalResponse> 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<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default)
|
||||
public async Task<ProposalResponse> 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue