proposal-system/api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs
Adam Moussa 6ece253f66 feat: pricing library — curated priced items feed the RAG corpus
Adds a managed pricing library so admins can seed/curate reference priced items
directly, instead of the corpus being populated only by ingesting Sent proposals.
v1 PR5.

API:
- PricingLibraryItem entity + migration; /api/pricing-library CRUD (admin), with
  GET {id} reachable by internal Lambda callers (admins role via internal key).
- Create/update publish an ADDITIVE library-ingest SQS job {pricingLibraryItemId},
  wrapped so a publish failure never rolls back the save.

Lambda (library-ingest):
- Additive event-shape branch: pricingLibraryItemId -> fetch item, format markdown,
  upload to pricing-library/{category}/{id}.md, trigger KB sync. The existing
  proposalId path is byte-for-byte unchanged. Explicit error when neither id present;
  warns when both present.

Web:
- Pricing Library management page (/admin/pricing-library): list / create / edit / delete.

GPT-4.1 cross-review on the event-shape change: no BLOCK (neither/both-id handling
applied). Verified: api 159 tests; web tsc + 26 tests; lambdas ruff + 37 pytest.
2026-06-18 12:42:23 -04:00

96 lines
3.3 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
namespace ProposalSystem.Api.Controllers;
/// <summary>
/// PR5: Pricing Library CRUD endpoints. Admins seed/manage reusable priced
/// reference items that feed the Bedrock RAG corpus.
///
/// GET {id} is reachable by the internal API key middleware (which assigns
/// the "admins" role) so the library-ingest Lambda can fetch item details.
/// </summary>
[ApiController]
[Route("api/pricing-library")]
[Authorize]
public class PricingLibraryController : ControllerBase
{
private readonly IPricingLibraryService _pricingLibraryService;
public PricingLibraryController(IPricingLibraryService pricingLibraryService)
{
_pricingLibraryService = pricingLibraryService;
}
[HttpGet("list")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(PagedResponse<PricingLibraryItemResponse>), 200)]
public async Task<ActionResult<PagedResponse<PricingLibraryItemResponse>>> List(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 25,
CancellationToken ct = default)
{
var result = await _pricingLibraryService.ListAsync(page, pageSize, ct);
return Ok(result);
}
/// <summary>
/// Get a single pricing library item. Accessible by admins/sysadmins role,
/// which includes internal Lambda callers (the internal API key middleware
/// assigns the "admins" role).
/// </summary>
[HttpGet("{id:guid}")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(PricingLibraryItemResponse), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<PricingLibraryItemResponse>> GetById(
Guid id,
CancellationToken ct)
{
var result = await _pricingLibraryService.GetByIdAsync(id, ct);
if (result == null) return NotFound();
return Ok(result);
}
[HttpPost]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(PricingLibraryItemResponse), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<PricingLibraryItemResponse>> Create(
[FromBody] CreatePricingLibraryItemRequest request,
CancellationToken ct)
{
var result = await _pricingLibraryService.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(PricingLibraryItemResponse), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<PricingLibraryItemResponse>> Update(
Guid id,
[FromBody] UpdatePricingLibraryItemRequest request,
CancellationToken ct)
{
var result = await _pricingLibraryService.UpdateAsync(id, request, ct);
if (result == null) return NotFound();
return Ok(result);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(204)]
[ProducesResponseType(404)]
public async Task<IActionResult> Delete(
Guid id,
CancellationToken ct)
{
var deleted = await _pricingLibraryService.DeleteAsync(id, ct);
if (!deleted) return NotFound();
return NoContent();
}
}