mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 10:28:55 +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.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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
|
#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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue