proposal-system/api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs
Adam Moussa 5d84399a0d
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
feat: pricing library — curated priced items feed the RAG corpus (#127)
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:49:47 -04:00

171 lines
5.7 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Infrastructure.Services;
/// <summary>
/// PR5: Pricing Library CRUD service. Publishes "library-ingest" SQS jobs
/// on create/update so the Bedrock KB corpus stays in sync.
/// </summary>
public class PricingLibraryService : IPricingLibraryService
{
private readonly ProposalDbContext _db;
private readonly IJobPublisher _jobPublisher;
private readonly ILogger<PricingLibraryService> _logger;
public PricingLibraryService(
ProposalDbContext db,
IJobPublisher jobPublisher,
ILogger<PricingLibraryService> logger)
{
_db = db;
_jobPublisher = jobPublisher;
_logger = logger;
}
public async Task<PagedResponse<PricingLibraryItemResponse>> ListAsync(int page, int pageSize, CancellationToken ct = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.PricingLibraryItems.AsNoTracking().AsQueryable();
var totalCount = await query.CountAsync(ct);
var items = await query
.OrderBy(i => i.Title)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return new PagedResponse<PricingLibraryItemResponse>(
items.Select(MapToResponse).ToList(),
totalCount,
page,
pageSize
);
}
public async Task<PricingLibraryItemResponse?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
var item = await _db.PricingLibraryItems
.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == id, ct);
return item == null ? null : MapToResponse(item);
}
public async Task<PricingLibraryItemResponse> CreateAsync(CreatePricingLibraryItemRequest request, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var item = new PricingLibraryItem
{
Id = Guid.NewGuid(),
Title = request.Title,
Description = request.Description,
ServiceCategory = request.ServiceCategory,
Unit = request.Unit,
UnitPrice = request.UnitPrice,
Keywords = request.Keywords,
Source = request.Source ?? "Manual",
CreatedAt = now,
UpdatedAt = now,
};
_db.PricingLibraryItems.Add(item);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("PricingLibraryItem {ItemId} created with title '{Title}'",
item.Id, item.Title);
// PR5: Trigger KB ingestion so the Bedrock RAG corpus picks up the new item.
// Wrap in try/catch so a publish failure does not roll back the save
// (matches the audit-no-rollback convention from API-H6).
try
{
await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id);
}
return MapToResponse(item);
}
public async Task<PricingLibraryItemResponse?> UpdateAsync(Guid id, UpdatePricingLibraryItemRequest request, CancellationToken ct = default)
{
var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct);
if (item == null) return null;
if (request.Title != null)
item.Title = request.Title;
if (request.Description != null)
item.Description = request.Description;
if (request.ServiceCategory.HasValue)
item.ServiceCategory = request.ServiceCategory.Value;
if (request.Unit != null)
item.Unit = request.Unit;
if (request.UnitPrice.HasValue)
item.UnitPrice = request.UnitPrice.Value;
if (request.Keywords != null)
item.Keywords = request.Keywords;
item.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
_logger.LogInformation("PricingLibraryItem {ItemId} updated", item.Id);
// PR5: Trigger KB re-ingestion so the Bedrock RAG corpus reflects the update.
// Wrap in try/catch so a publish failure does not roll back the save
// (matches the audit-no-rollback convention from API-H6).
try
{
await _jobPublisher.PublishAsync("library-ingest", new { pricingLibraryItemId = item.Id }, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish library-ingest job for PricingLibraryItem {ItemId}", item.Id);
}
return MapToResponse(item);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var item = await _db.PricingLibraryItems.FindAsync(new object[] { id }, ct);
if (item == null) return false;
_db.PricingLibraryItems.Remove(item);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("PricingLibraryItem {ItemId} deleted", item.Id);
// TODO: PR5 follow-up — remove the corresponding markdown document from the
// Bedrock Knowledge Base S3 bucket so stale items are not returned by RAG queries.
return true;
}
private static PricingLibraryItemResponse MapToResponse(PricingLibraryItem item) => new(
item.Id,
item.Title,
item.Description,
item.ServiceCategory,
item.Unit,
item.UnitPrice,
item.Keywords,
item.Source,
item.CreatedAt,
item.UpdatedAt
);
}