mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 16:18:57 +00:00
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).
176 lines
6.2 KiB
C#
176 lines
6.2 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
|
using ProposalSystem.Application.DTOs;
|
|
using ProposalSystem.Application.Interfaces;
|
|
|
|
namespace ProposalSystem.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/proposals")]
|
|
[Authorize]
|
|
public class ProposalsController : ControllerBase
|
|
{
|
|
private readonly IProposalService _proposalService;
|
|
private readonly IJobPublisher _jobPublisher;
|
|
private readonly ISimilarProposalService _similarService;
|
|
|
|
public ProposalsController(
|
|
IProposalService proposalService,
|
|
IJobPublisher jobPublisher,
|
|
ISimilarProposalService similarService)
|
|
{
|
|
_proposalService = proposalService;
|
|
_jobPublisher = jobPublisher;
|
|
_similarService = similarService;
|
|
}
|
|
|
|
[HttpPost]
|
|
[ProducesResponseType(typeof(ProposalResponse), 201)]
|
|
[ProducesResponseType(400)]
|
|
public async Task<ActionResult<ProposalResponse>> Create(
|
|
[FromBody] CreateProposalRequest request,
|
|
CancellationToken ct)
|
|
{
|
|
var result = await _proposalService.CreateAsync(request, ct);
|
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
|
}
|
|
|
|
[HttpGet]
|
|
[ProducesResponseType(typeof(PagedResponse<ProposalListResponse>), 200)]
|
|
public async Task<ActionResult<PagedResponse<ProposalListResponse>>> GetAll(
|
|
[FromQuery] ProposalFilterRequest filter,
|
|
CancellationToken ct)
|
|
{
|
|
var result = await _proposalService.GetAllAsync(filter, ct);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
[ProducesResponseType(typeof(ProposalResponse), 200)]
|
|
[ProducesResponseType(404)]
|
|
public async Task<ActionResult<ProposalResponse>> GetById(Guid id, CancellationToken ct)
|
|
{
|
|
var result = await _proposalService.GetByIdAsync(id, ct);
|
|
if (result == null) return NotFound();
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpPut("{id:guid}")]
|
|
[Authorize(Roles = "admins,sysadmins")]
|
|
[ProducesResponseType(typeof(ProposalResponse), 200)]
|
|
[ProducesResponseType(400)]
|
|
[ProducesResponseType(404)]
|
|
public async Task<ActionResult<ProposalResponse>> Update(
|
|
Guid id,
|
|
[FromBody] UpdateProposalRequest request,
|
|
CancellationToken ct)
|
|
{
|
|
var result = await _proposalService.UpdateAsync(id, request, ct);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/approve")]
|
|
[Authorize(Roles = "admins,sysadmins")]
|
|
[ProducesResponseType(typeof(ProposalResponse), 200)]
|
|
[ProducesResponseType(400)]
|
|
[ProducesResponseType(404)]
|
|
public async Task<ActionResult<ProposalResponse>> Approve(
|
|
Guid id,
|
|
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request,
|
|
CancellationToken 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,
|
|
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request,
|
|
CancellationToken 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,
|
|
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request,
|
|
CancellationToken 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,
|
|
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] ProposalVersionRequest? request,
|
|
CancellationToken ct)
|
|
{
|
|
var result = await _proposalService.ReviseAsync(id, request?.ProposalVersion, ct);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpGet("{id:guid}/history")]
|
|
public async Task<ActionResult<IReadOnlyList<ProposalResponse>>> GetHistory(Guid id, CancellationToken ct)
|
|
{
|
|
var result = await _proposalService.GetRevisionHistoryAsync(id, ct);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpGet("{id:guid}/audit")]
|
|
[Authorize(Roles = "admins,sysadmins")]
|
|
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> GetAudit(Guid id, CancellationToken ct)
|
|
{
|
|
var result = await _proposalService.GetAuditTrailAsync(id, ct);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpGet("{id:guid}/similar")]
|
|
[Authorize(Roles = "admins,sysadmins")]
|
|
public async Task<ActionResult<IReadOnlyList<SimilarProposalResponse>>> GetSimilar(Guid id, CancellationToken ct)
|
|
{
|
|
var result = await _similarService.GetSimilarProposalsAsync(id, ct);
|
|
return Ok(result);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/generate-suggestions")]
|
|
[Authorize(Roles = "admins,sysadmins")]
|
|
public async Task<IActionResult> GenerateSuggestions(Guid id, CancellationToken ct)
|
|
{
|
|
await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "generate" }, ct);
|
|
return Accepted();
|
|
}
|
|
|
|
[HttpPost("{id:guid}/regenerate")]
|
|
[Authorize(Roles = "admins,sysadmins")]
|
|
public async Task<IActionResult> Regenerate(Guid id, CancellationToken ct)
|
|
{
|
|
await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct);
|
|
return Accepted();
|
|
}
|
|
|
|
[HttpGet("stats")]
|
|
public async Task<ActionResult<ProposalStatsResponse>> GetStats(CancellationToken ct)
|
|
{
|
|
var stats = await _proposalService.GetStatsAsync(ct);
|
|
return Ok(stats);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/similar-references")]
|
|
[Authorize(Roles = "admins,sysadmins")]
|
|
public async Task<IActionResult> AddSimilarReference(
|
|
Guid id,
|
|
[FromBody] CreateSimilarReferenceRequest request,
|
|
CancellationToken ct)
|
|
{
|
|
await _similarService.AddReferenceAsync(id, request, ct);
|
|
return Created();
|
|
}
|
|
}
|