From 5d84399a0db6293889456bf1f63ef4c4a7f60028 Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Thu, 18 Jun 2026 12:49:47 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20pricing=20library=20=E2=80=94=20curated?=
=?UTF-8?q?=20priced=20items=20feed=20the=20RAG=20corpus=20(#127)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Controllers/PricingLibraryController.cs | 96 +++
api/src/ProposalSystem.Api/Program.cs | 1 +
.../DTOs/PricingLibraryDtos.cs | 38 ++
.../Interfaces/IPricingLibraryService.cs | 15 +
.../CreatePricingLibraryItemValidator.cs | 39 ++
.../UpdatePricingLibraryItemValidator.cs | 38 ++
.../Entities/PricingLibraryItem.cs | 19 +
...260612225458_AddPricingLibrary.Designer.cs | 552 ++++++++++++++++++
.../20260612225458_AddPricingLibrary.cs | 42 ++
.../ProposalDbContextModelSnapshot.cs | 42 ++
.../Data/ProposalDbContext.cs | 9 +
.../Services/PricingLibraryService.cs | 171 ++++++
.../AuthorizationAttributeTests.cs | 44 ++
.../Services/PricingLibraryServiceTests.cs | 326 +++++++++++
.../CreatePricingLibraryItemValidatorTests.cs | 239 ++++++++
.../UpdatePricingLibraryItemValidatorTests.cs | 121 ++++
lambdas/library-ingest/app.py | 128 +++-
lambdas/tests/test_library_ingest.py | 235 ++++++++
shared/api-contracts/package-lock.json | 29 +
shared/api-contracts/src/index.ts | 33 ++
web/src/App.tsx | 2 +
web/src/components/Sidebar.tsx | 2 +
web/src/constants/queryKeys.ts | 1 +
web/src/lib/api/pricingLibrary.ts | 72 +++
.../pricing-library/PricingLibraryPage.tsx | 479 +++++++++++++++
25 files changed, 2768 insertions(+), 5 deletions(-)
create mode 100644 api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs
create mode 100644 api/src/ProposalSystem.Application/DTOs/PricingLibraryDtos.cs
create mode 100644 api/src/ProposalSystem.Application/Interfaces/IPricingLibraryService.cs
create mode 100644 api/src/ProposalSystem.Application/Validators/CreatePricingLibraryItemValidator.cs
create mode 100644 api/src/ProposalSystem.Application/Validators/UpdatePricingLibraryItemValidator.cs
create mode 100644 api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs
create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs
create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.cs
create mode 100644 api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs
create mode 100644 api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs
create mode 100644 api/tests/ProposalSystem.Tests/Validators/CreatePricingLibraryItemValidatorTests.cs
create mode 100644 api/tests/ProposalSystem.Tests/Validators/UpdatePricingLibraryItemValidatorTests.cs
create mode 100644 lambdas/tests/test_library_ingest.py
create mode 100644 shared/api-contracts/package-lock.json
create mode 100644 web/src/lib/api/pricingLibrary.ts
create mode 100644 web/src/pages/admin/pricing-library/PricingLibraryPage.tsx
diff --git a/api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs b/api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs
new file mode 100644
index 0000000..28617f3
--- /dev/null
+++ b/api/src/ProposalSystem.Api/Controllers/PricingLibraryController.cs
@@ -0,0 +1,96 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using ProposalSystem.Application.DTOs;
+using ProposalSystem.Application.Interfaces;
+
+namespace ProposalSystem.Api.Controllers;
+
+///
+/// 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.
+///
+[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), 200)]
+ public async Task>> List(
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 25,
+ CancellationToken ct = default)
+ {
+ var result = await _pricingLibraryService.ListAsync(page, pageSize, ct);
+ return Ok(result);
+ }
+
+ ///
+ /// 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).
+ ///
+ [HttpGet("{id:guid}")]
+ [Authorize(Roles = "admins,sysadmins")]
+ [ProducesResponseType(typeof(PricingLibraryItemResponse), 200)]
+ [ProducesResponseType(404)]
+ public async Task> 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> 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> 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 Delete(
+ Guid id,
+ CancellationToken ct)
+ {
+ var deleted = await _pricingLibraryService.DeleteAsync(id, ct);
+ if (!deleted) return NotFound();
+ return NoContent();
+ }
+}
diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs
index fcb431e..0fac998 100644
--- a/api/src/ProposalSystem.Api/Program.cs
+++ b/api/src/ProposalSystem.Api/Program.cs
@@ -137,6 +137,7 @@ else
builder.Services.AddScoped();
}
builder.Services.AddScoped();
+builder.Services.AddScoped();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))
{
diff --git a/api/src/ProposalSystem.Application/DTOs/PricingLibraryDtos.cs b/api/src/ProposalSystem.Application/DTOs/PricingLibraryDtos.cs
new file mode 100644
index 0000000..0dcec96
--- /dev/null
+++ b/api/src/ProposalSystem.Application/DTOs/PricingLibraryDtos.cs
@@ -0,0 +1,38 @@
+using ProposalSystem.Domain.Entities;
+
+namespace ProposalSystem.Application.DTOs;
+
+///
+/// PR5: DTOs for the Pricing Library CRUD endpoints.
+///
+public record PricingLibraryItemResponse(
+ Guid Id,
+ string Title,
+ string? Description,
+ ServiceCategory ServiceCategory,
+ string? Unit,
+ decimal? UnitPrice,
+ string? Keywords,
+ string Source,
+ DateTime CreatedAt,
+ DateTime UpdatedAt
+);
+
+public record CreatePricingLibraryItemRequest(
+ string Title,
+ string? Description,
+ ServiceCategory ServiceCategory,
+ string? Unit,
+ decimal? UnitPrice,
+ string? Keywords,
+ string? Source
+);
+
+public record UpdatePricingLibraryItemRequest(
+ string? Title,
+ string? Description,
+ ServiceCategory? ServiceCategory,
+ string? Unit,
+ decimal? UnitPrice,
+ string? Keywords
+);
diff --git a/api/src/ProposalSystem.Application/Interfaces/IPricingLibraryService.cs b/api/src/ProposalSystem.Application/Interfaces/IPricingLibraryService.cs
new file mode 100644
index 0000000..f6d011e
--- /dev/null
+++ b/api/src/ProposalSystem.Application/Interfaces/IPricingLibraryService.cs
@@ -0,0 +1,15 @@
+using ProposalSystem.Application.DTOs;
+
+namespace ProposalSystem.Application.Interfaces;
+
+///
+/// PR5: Service interface for Pricing Library CRUD operations.
+///
+public interface IPricingLibraryService
+{
+ Task> ListAsync(int page, int pageSize, CancellationToken ct = default);
+ Task GetByIdAsync(Guid id, CancellationToken ct = default);
+ Task CreateAsync(CreatePricingLibraryItemRequest request, CancellationToken ct = default);
+ Task UpdateAsync(Guid id, UpdatePricingLibraryItemRequest request, CancellationToken ct = default);
+ Task DeleteAsync(Guid id, CancellationToken ct = default);
+}
diff --git a/api/src/ProposalSystem.Application/Validators/CreatePricingLibraryItemValidator.cs b/api/src/ProposalSystem.Application/Validators/CreatePricingLibraryItemValidator.cs
new file mode 100644
index 0000000..512b9ff
--- /dev/null
+++ b/api/src/ProposalSystem.Application/Validators/CreatePricingLibraryItemValidator.cs
@@ -0,0 +1,39 @@
+using FluentValidation;
+using ProposalSystem.Application.DTOs;
+
+namespace ProposalSystem.Application.Validators;
+
+///
+/// PR5: Validates CreatePricingLibraryItemRequest payloads.
+///
+public class CreatePricingLibraryItemValidator : AbstractValidator
+{
+ public CreatePricingLibraryItemValidator()
+ {
+ RuleFor(x => x.Title)
+ .NotEmpty().WithMessage("Title is required")
+ .MaximumLength(200);
+
+ RuleFor(x => x.Description)
+ .MaximumLength(5000)
+ .When(x => x.Description != null);
+
+ RuleFor(x => x.ServiceCategory).IsInEnum();
+
+ RuleFor(x => x.Unit)
+ .MaximumLength(50)
+ .When(x => x.Unit != null);
+
+ RuleFor(x => x.UnitPrice)
+ .GreaterThanOrEqualTo(0).WithMessage("Unit price must be zero or positive")
+ .When(x => x.UnitPrice.HasValue);
+
+ RuleFor(x => x.Keywords)
+ .MaximumLength(2000)
+ .When(x => x.Keywords != null);
+
+ RuleFor(x => x.Source)
+ .MaximumLength(100)
+ .When(x => x.Source != null);
+ }
+}
diff --git a/api/src/ProposalSystem.Application/Validators/UpdatePricingLibraryItemValidator.cs b/api/src/ProposalSystem.Application/Validators/UpdatePricingLibraryItemValidator.cs
new file mode 100644
index 0000000..ae236d0
--- /dev/null
+++ b/api/src/ProposalSystem.Application/Validators/UpdatePricingLibraryItemValidator.cs
@@ -0,0 +1,38 @@
+using FluentValidation;
+using ProposalSystem.Application.DTOs;
+
+namespace ProposalSystem.Application.Validators;
+
+///
+/// PR5: Validates UpdatePricingLibraryItemRequest payloads.
+///
+public class UpdatePricingLibraryItemValidator : AbstractValidator
+{
+ public UpdatePricingLibraryItemValidator()
+ {
+ RuleFor(x => x.Title)
+ .NotEmpty().WithMessage("Title cannot be empty when provided")
+ .MaximumLength(200)
+ .When(x => x.Title != null);
+
+ RuleFor(x => x.Description)
+ .MaximumLength(5000)
+ .When(x => x.Description != null);
+
+ RuleFor(x => x.ServiceCategory)
+ .IsInEnum()
+ .When(x => x.ServiceCategory.HasValue);
+
+ RuleFor(x => x.Unit)
+ .MaximumLength(50)
+ .When(x => x.Unit != null);
+
+ RuleFor(x => x.UnitPrice)
+ .GreaterThanOrEqualTo(0).WithMessage("Unit price must be zero or positive")
+ .When(x => x.UnitPrice.HasValue);
+
+ RuleFor(x => x.Keywords)
+ .MaximumLength(2000)
+ .When(x => x.Keywords != null);
+ }
+}
diff --git a/api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs b/api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs
new file mode 100644
index 0000000..d6dbb19
--- /dev/null
+++ b/api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs
@@ -0,0 +1,19 @@
+namespace ProposalSystem.Domain.Entities;
+
+///
+/// PR5: Reusable priced reference item for the Bedrock RAG pricing library.
+/// Admins seed/manage these to enrich the corpus beyond Sent proposals.
+///
+public class PricingLibraryItem
+{
+ public Guid Id { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public ServiceCategory ServiceCategory { get; set; }
+ public string? Unit { get; set; }
+ public decimal? UnitPrice { get; set; }
+ public string? Keywords { get; set; }
+ public string Source { get; set; } = "Manual";
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs
new file mode 100644
index 0000000..11d2674
--- /dev/null
+++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs
@@ -0,0 +1,552 @@
+//
+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("20260612225458_AddPricingLibrary")]
+ partial class AddPricingLibrary
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.27")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Details")
+ .HasColumnType("jsonb");
+
+ b.Property("IpAddress")
+ .HasColumnType("text");
+
+ b.Property("ProposalId")
+ .HasColumnType("uuid");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Addresses")
+ .HasColumnType("jsonb");
+
+ b.Property("ContactEmail")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("Customers");
+ });
+
+ modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("GeneratedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GeneratedById")
+ .HasColumnType("uuid");
+
+ b.Property("ProposalId")
+ .HasColumnType("uuid");
+
+ b.Property("Revision")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PricingMode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProposalId")
+ .HasColumnType("uuid");
+
+ b.Property("Quantity")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TotalPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("Unit")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProposalId");
+
+ b.ToTable("LineItems");
+ });
+
+ modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Keywords")
+ .HasColumnType("text");
+
+ b.Property("ServiceCategory")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Unit")
+ .HasColumnType("text");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("PricingLibraryItems");
+ });
+
+ modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ApprovedById")
+ .HasColumnType("uuid");
+
+ b.Property("AssignedAdminId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentRevision")
+ .HasColumnType("integer");
+
+ b.Property("CustomerAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CustomerName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ParentProposalId")
+ .HasColumnType("uuid");
+
+ b.Property("PoNumber")
+ .HasColumnType("text");
+
+ b.Property("Priority")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProposalNumber")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RefinedScope")
+ .HasColumnType("text");
+
+ b.Property("ScopeOfWork")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SentAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceCategory")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SubmittedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SubmittedById")
+ .HasColumnType("uuid");
+
+ b.Property("TotalBidAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VendorTotalCost")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ProposalId")
+ .HasColumnType("uuid");
+
+ b.Property("ReferencedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReferencedById")
+ .HasColumnType("uuid");
+
+ b.Property("ReferencedLibraryItemId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SimilarityScore")
+ .HasColumnType("real");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProposalId");
+
+ b.HasIndex("ReferencedById");
+
+ b.ToTable("SimilarProposalReferences");
+ });
+
+ modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CognitoSub")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ExtractedData")
+ .HasColumnType("jsonb");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProcessingStatus")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProposalId")
+ .HasColumnType("uuid");
+
+ b.Property("S3Key")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TotalVendorCost")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.cs
new file mode 100644
index 0000000..ee4b9ba
--- /dev/null
+++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.cs
@@ -0,0 +1,42 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ProposalSystem.Infrastructure.Data.Migrations
+{
+ ///
+ public partial class AddPricingLibrary : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "PricingLibraryItems",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Title = table.Column(type: "text", nullable: false),
+ Description = table.Column(type: "text", nullable: true),
+ ServiceCategory = table.Column(type: "text", nullable: false),
+ Unit = table.Column(type: "text", nullable: true),
+ UnitPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
+ Keywords = table.Column(type: "text", nullable: true),
+ Source = table.Column(type: "text", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_PricingLibraryItems", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "PricingLibraryItems");
+ }
+ }
+}
diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs
index 7f964a1..0aeb2a3 100644
--- a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs
+++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs
@@ -169,6 +169,48 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.ToTable("LineItems");
});
+ modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Keywords")
+ .HasColumnType("text");
+
+ b.Property("ServiceCategory")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Unit")
+ .HasColumnType("text");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("PricingLibraryItems");
+ });
+
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Property("Id")
diff --git a/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs
index 70b882e..bc7aaf9 100644
--- a/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs
+++ b/api/src/ProposalSystem.Infrastructure/Data/ProposalDbContext.cs
@@ -17,6 +17,7 @@ public class ProposalDbContext : DbContext
public DbSet AuditLogs => Set();
public DbSet SimilarProposalReferences => Set();
public DbSet GeneratedPdfs => Set();
+ public DbSet PricingLibraryItems => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -92,5 +93,13 @@ public class ProposalDbContext : DbContext
entity.HasKey(e => e.Id);
entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade);
});
+
+ // PR5: Pricing Library entity configuration
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.UnitPrice).HasPrecision(18, 2);
+ entity.Property(e => e.ServiceCategory).HasConversion();
+ });
}
}
diff --git a/api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs b/api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs
new file mode 100644
index 0000000..c414250
--- /dev/null
+++ b/api/src/ProposalSystem.Infrastructure/Services/PricingLibraryService.cs
@@ -0,0 +1,171 @@
+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;
+
+///
+/// PR5: Pricing Library CRUD service. Publishes "library-ingest" SQS jobs
+/// on create/update so the Bedrock KB corpus stays in sync.
+///
+public class PricingLibraryService : IPricingLibraryService
+{
+ private readonly ProposalDbContext _db;
+ private readonly IJobPublisher _jobPublisher;
+ private readonly ILogger _logger;
+
+ public PricingLibraryService(
+ ProposalDbContext db,
+ IJobPublisher jobPublisher,
+ ILogger logger)
+ {
+ _db = db;
+ _jobPublisher = jobPublisher;
+ _logger = logger;
+ }
+
+ public async Task> 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(
+ items.Select(MapToResponse).ToList(),
+ totalCount,
+ page,
+ pageSize
+ );
+ }
+
+ public async Task 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 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 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 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
+ );
+}
diff --git a/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs
index b6e4a41..c17b354 100644
--- a/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs
+++ b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs
@@ -172,6 +172,7 @@ public class AuthorizationAttributeTests
[InlineData(typeof(GeneratedPdfsController))]
[InlineData(typeof(FilesController))]
[InlineData(typeof(VendorProposalsController))]
+ [InlineData(typeof(PricingLibraryController))]
public void AllControllers_HaveAuthorizeAttribute(Type controllerType)
{
var attr = controllerType.GetCustomAttribute();
@@ -180,4 +181,47 @@ public class AuthorizationAttributeTests
}
#endregion
+
+ #region PR5: PricingLibraryController Authorization
+
+ [Fact(DisplayName = "PR5: PricingLibraryController requires authentication")]
+ public void PricingLibraryController_HasAuthorizeAttribute()
+ {
+ typeof(PricingLibraryController)
+ .GetCustomAttribute()
+ .Should().NotBeNull("PricingLibraryController should require authentication");
+ }
+
+ [Theory(DisplayName = "PR5: PricingLibrary admin actions require admins/sysadmins role")]
+ [InlineData("List")]
+ [InlineData("GetById")]
+ [InlineData("Create")]
+ [InlineData("Update")]
+ [InlineData("Delete")]
+ public void PricingLibraryController_AdminActions_RequireAdminRole(string methodName)
+ {
+ var method = typeof(PricingLibraryController).GetMethod(methodName);
+ method.Should().NotBeNull($"PricingLibraryController should have a {methodName} method");
+
+ var attr = method!.GetCustomAttribute();
+ attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
+ attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
+ attr.Roles!.Split(',').Select(r => r.Trim()).Should()
+ .Contain("admins", $"{methodName} should allow admins (including internal Lambda callers)")
+ .And.Contain("sysadmins", $"{methodName} should allow sysadmins");
+ }
+
+ [Fact(DisplayName = "PR5: PricingLibrary GetById is reachable by internal Lambda callers (admins role)")]
+ public void PricingLibraryController_GetById_AllowsAdminsRole()
+ {
+ var method = typeof(PricingLibraryController).GetMethod("GetById");
+ method.Should().NotBeNull();
+
+ var attr = method!.GetCustomAttribute();
+ attr.Should().NotBeNull();
+ attr!.Roles.Should().Contain("admins",
+ "GetById must be reachable by the internal API key middleware which assigns the 'admins' role");
+ }
+
+ #endregion
}
diff --git a/api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs b/api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs
new file mode 100644
index 0000000..90113f4
--- /dev/null
+++ b/api/tests/ProposalSystem.Tests/Services/PricingLibraryServiceTests.cs
@@ -0,0 +1,326 @@
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using ProposalSystem.Application.DTOs;
+using ProposalSystem.Application.Interfaces;
+using ProposalSystem.Domain.Entities;
+using ProposalSystem.Infrastructure.Services;
+using ProposalSystem.Tests.Helpers;
+using Xunit;
+
+namespace ProposalSystem.Tests.Services;
+
+///
+/// PR5: Tests for PricingLibraryService. Verifies CRUD behavior and that
+/// create/update publish "library-ingest" SQS jobs for KB sync.
+///
+public class PricingLibraryServiceTests : IDisposable
+{
+ private readonly Infrastructure.Data.ProposalDbContext _db;
+ private readonly IJobPublisher _jobPublisher;
+ private readonly PricingLibraryService _sut;
+
+ public PricingLibraryServiceTests()
+ {
+ _db = DbContextFactory.Create();
+ _jobPublisher = Substitute.For();
+
+ _sut = new PricingLibraryService(
+ _db,
+ _jobPublisher,
+ Substitute.For>());
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ }
+
+ #region Create
+
+ [Fact(DisplayName = "PR5: Create returns item with generated Id and timestamps")]
+ public async Task CreateAsync_ReturnsItemWithIdAndTimestamps()
+ {
+ var request = new CreatePricingLibraryItemRequest(
+ Title: "HVAC Filter Replacement",
+ Description: "Standard filter replacement service",
+ ServiceCategory: ServiceCategory.HVAC,
+ Unit: "ea",
+ UnitPrice: 45.00m,
+ Keywords: "filter HVAC maintenance",
+ Source: "Manual"
+ );
+
+ var result = await _sut.CreateAsync(request);
+
+ result.Id.Should().NotBe(Guid.Empty);
+ result.Title.Should().Be("HVAC Filter Replacement");
+ result.Description.Should().Be("Standard filter replacement service");
+ result.ServiceCategory.Should().Be(ServiceCategory.HVAC);
+ result.Unit.Should().Be("ea");
+ result.UnitPrice.Should().Be(45.00m);
+ result.Keywords.Should().Be("filter HVAC maintenance");
+ result.Source.Should().Be("Manual");
+ result.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
+ result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
+ }
+
+ [Fact(DisplayName = "PR5: Create defaults Source to 'Manual' when null")]
+ public async Task CreateAsync_DefaultsSourceToManual()
+ {
+ var request = new CreatePricingLibraryItemRequest(
+ Title: "Basic Item",
+ Description: null,
+ ServiceCategory: ServiceCategory.General,
+ Unit: null,
+ UnitPrice: null,
+ Keywords: null,
+ Source: null
+ );
+
+ var result = await _sut.CreateAsync(request);
+
+ result.Source.Should().Be("Manual");
+ }
+
+ [Fact(DisplayName = "PR5: Create publishes library-ingest job with pricingLibraryItemId")]
+ public async Task CreateAsync_PublishesLibraryIngestJob()
+ {
+ var request = new CreatePricingLibraryItemRequest(
+ Title: "Test Item",
+ Description: null,
+ ServiceCategory: ServiceCategory.General,
+ Unit: null,
+ UnitPrice: null,
+ Keywords: null,
+ Source: null
+ );
+
+ var result = await _sut.CreateAsync(request);
+
+ await _jobPublisher.Received(1).PublishAsync(
+ "library-ingest",
+ Arg.Is