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(o => o.ToString()!.Contains(result.Id.ToString())), + Arg.Any()); + } + + [Fact(DisplayName = "PR5: Create succeeds even when job publish fails (no rollback)")] + public async Task CreateAsync_JobPublishFails_DoesNotRollBack() + { + _jobPublisher.PublishAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new InvalidOperationException("SQS is down")); + + var request = new CreatePricingLibraryItemRequest( + Title: "Resilient Item", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = await _sut.CreateAsync(request); + + result.Title.Should().Be("Resilient Item"); + // Verify item was persisted despite job publish failure + var dbItem = await _db.PricingLibraryItems.FindAsync(result.Id); + dbItem.Should().NotBeNull(); + } + + #endregion + + #region Update + + [Fact(DisplayName = "PR5: Update modifies provided fields and publishes ingest job")] + public async Task UpdateAsync_ModifiesFieldsAndPublishesJob() + { + // Seed an item + var originalUpdatedAt = DateTime.UtcNow.AddDays(-1); + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "Original Title", + Description = "Original", + ServiceCategory = ServiceCategory.General, + Unit = "ea", + UnitPrice = 10.00m, + Keywords = "original", + Source = "Manual", + CreatedAt = DateTime.UtcNow.AddDays(-1), + UpdatedAt = originalUpdatedAt, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + var request = new UpdatePricingLibraryItemRequest( + Title: "Updated Title", + Description: null, + ServiceCategory: ServiceCategory.Electrical, + Unit: null, + UnitPrice: 99.99m, + Keywords: null + ); + + var result = await _sut.UpdateAsync(item.Id, request); + + result.Should().NotBeNull(); + result!.Title.Should().Be("Updated Title"); + result.ServiceCategory.Should().Be(ServiceCategory.Electrical); + result.UnitPrice.Should().Be(99.99m); + result.UpdatedAt.Should().BeAfter(originalUpdatedAt); + + await _jobPublisher.Received(1).PublishAsync( + "library-ingest", + Arg.Is(o => o.ToString()!.Contains(item.Id.ToString())), + Arg.Any()); + } + + [Fact(DisplayName = "PR5: Update returns null for non-existent item")] + public async Task UpdateAsync_NonExistentItem_ReturnsNull() + { + var request = new UpdatePricingLibraryItemRequest( + Title: "Does Not Exist", + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = await _sut.UpdateAsync(Guid.NewGuid(), request); + + result.Should().BeNull(); + } + + [Fact(DisplayName = "PR5: Update succeeds even when job publish fails (no rollback)")] + public async Task UpdateAsync_JobPublishFails_DoesNotRollBack() + { + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "Will Be Updated", + ServiceCategory = ServiceCategory.General, + Source = "Manual", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + _jobPublisher.PublishAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new InvalidOperationException("SQS is down")); + + var request = new UpdatePricingLibraryItemRequest( + Title: "Updated Despite SQS Failure", + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = await _sut.UpdateAsync(item.Id, request); + + result.Should().NotBeNull(); + result!.Title.Should().Be("Updated Despite SQS Failure"); + } + + #endregion + + #region GetById + + [Fact(DisplayName = "PR5: GetById returns item when it exists")] + public async Task GetByIdAsync_ExistingItem_ReturnsItem() + { + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "Findable Item", + ServiceCategory = ServiceCategory.Plumbing, + Source = "Manual", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + var result = await _sut.GetByIdAsync(item.Id); + + result.Should().NotBeNull(); + result!.Id.Should().Be(item.Id); + result.Title.Should().Be("Findable Item"); + } + + [Fact(DisplayName = "PR5: GetById returns null for non-existent item")] + public async Task GetByIdAsync_NonExistentItem_ReturnsNull() + { + var result = await _sut.GetByIdAsync(Guid.NewGuid()); + + result.Should().BeNull(); + } + + #endregion + + #region Delete + + [Fact(DisplayName = "PR5: Delete removes item and returns true")] + public async Task DeleteAsync_ExistingItem_RemovesAndReturnsTrue() + { + var item = new PricingLibraryItem + { + Id = Guid.NewGuid(), + Title = "To Be Deleted", + ServiceCategory = ServiceCategory.General, + Source = "Manual", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.PricingLibraryItems.Add(item); + await _db.SaveChangesAsync(); + + var result = await _sut.DeleteAsync(item.Id); + + result.Should().BeTrue(); + var dbItem = await _db.PricingLibraryItems.FindAsync(item.Id); + dbItem.Should().BeNull(); + } + + [Fact(DisplayName = "PR5: Delete returns false for non-existent item")] + public async Task DeleteAsync_NonExistentItem_ReturnsFalse() + { + var result = await _sut.DeleteAsync(Guid.NewGuid()); + + result.Should().BeFalse(); + } + + #endregion + + #region List + + [Fact(DisplayName = "PR5: List returns paginated results ordered by title")] + public async Task ListAsync_ReturnsPaginatedResults() + { + // Seed 3 items + var items = new[] + { + new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Charlie", ServiceCategory = ServiceCategory.General, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Alpha", ServiceCategory = ServiceCategory.HVAC, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Bravo", ServiceCategory = ServiceCategory.Plumbing, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }, + }; + _db.PricingLibraryItems.AddRange(items); + await _db.SaveChangesAsync(); + + var result = await _sut.ListAsync(1, 2); + + result.TotalCount.Should().Be(3); + result.Page.Should().Be(1); + result.PageSize.Should().Be(2); + result.Items.Should().HaveCount(2); + result.Items[0].Title.Should().Be("Alpha"); + result.Items[1].Title.Should().Be("Bravo"); + } + + #endregion +} diff --git a/api/tests/ProposalSystem.Tests/Validators/CreatePricingLibraryItemValidatorTests.cs b/api/tests/ProposalSystem.Tests/Validators/CreatePricingLibraryItemValidatorTests.cs new file mode 100644 index 0000000..843b738 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Validators/CreatePricingLibraryItemValidatorTests.cs @@ -0,0 +1,239 @@ +using FluentAssertions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Validators; +using ProposalSystem.Domain.Entities; +using Xunit; + +namespace ProposalSystem.Tests.Validators; + +/// +/// PR5: Tests for CreatePricingLibraryItemValidator. +/// +public class CreatePricingLibraryItemValidatorTests +{ + private readonly CreatePricingLibraryItemValidator _sut = new(); + + [Fact(DisplayName = "PR5: Valid pricing library item passes validation")] + public void ValidRequest_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "HVAC Filter Replacement", + Description: "Standard 20x25x1 MERV-13 filter replacement", + ServiceCategory: ServiceCategory.HVAC, + Unit: "ea", + UnitPrice: 45.00m, + Keywords: "filter HVAC maintenance preventive", + Source: "Manual" + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Empty title fails validation")] + public void EmptyTitle_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Title exceeding 200 chars fails")] + public void Title_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: new string('X', 201), + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Description exceeding 5000 chars fails")] + public void Description_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: new string('X', 5001), + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Description"); + } + + [Fact(DisplayName = "PR5: Negative UnitPrice fails")] + public void NegativeUnitPrice_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.Plumbing, + Unit: "hr", + UnitPrice: -10.00m, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice"); + } + + [Fact(DisplayName = "PR5: Zero UnitPrice is valid")] + public void ZeroUnitPrice_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Complimentary Inspection", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: "ea", + UnitPrice: 0m, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Null UnitPrice is valid")] + public void NullUnitPrice_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Variable Price Item", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Keywords exceeding 2000 chars fails")] + public void Keywords_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: new string('X', 2001), + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Keywords"); + } + + [Fact(DisplayName = "PR5: Invalid ServiceCategory enum value fails")] + public void InvalidServiceCategory_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: (ServiceCategory)999, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory"); + } + + [Fact(DisplayName = "PR5: Unit exceeding 50 chars fails")] + public void Unit_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: new string('X', 51), + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Unit"); + } + + [Fact(DisplayName = "PR5: Source exceeding 100 chars fails")] + public void Source_TooLong_Fails() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Valid Title", + Description: null, + ServiceCategory: ServiceCategory.General, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: new string('X', 101) + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Source"); + } + + [Fact(DisplayName = "PR5: Minimal valid request (title + category only) passes")] + public void MinimalValidRequest_Passes() + { + var request = new CreatePricingLibraryItemRequest( + Title: "Basic Item", + Description: null, + ServiceCategory: ServiceCategory.Other, + Unit: null, + UnitPrice: null, + Keywords: null, + Source: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } +} diff --git a/api/tests/ProposalSystem.Tests/Validators/UpdatePricingLibraryItemValidatorTests.cs b/api/tests/ProposalSystem.Tests/Validators/UpdatePricingLibraryItemValidatorTests.cs new file mode 100644 index 0000000..edabecc --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Validators/UpdatePricingLibraryItemValidatorTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Validators; +using ProposalSystem.Domain.Entities; +using Xunit; + +namespace ProposalSystem.Tests.Validators; + +/// +/// PR5: Tests for UpdatePricingLibraryItemValidator. +/// +public class UpdatePricingLibraryItemValidatorTests +{ + private readonly UpdatePricingLibraryItemValidator _sut = new(); + + [Fact(DisplayName = "PR5: Valid update request passes")] + public void ValidRequest_Passes() + { + var request = new UpdatePricingLibraryItemRequest( + Title: "Updated Title", + Description: "Updated description", + ServiceCategory: ServiceCategory.Electrical, + Unit: "hr", + UnitPrice: 125.50m, + Keywords: "electrical wiring" + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: All-null update request passes (no changes)")] + public void AllNullRequest_Passes() + { + var request = new UpdatePricingLibraryItemRequest( + Title: null, + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact(DisplayName = "PR5: Empty title when provided fails")] + public void EmptyTitle_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: "", + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Title exceeding 200 chars fails")] + public void Title_TooLong_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: new string('X', 201), + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Title"); + } + + [Fact(DisplayName = "PR5: Negative UnitPrice fails")] + public void NegativeUnitPrice_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: null, + Description: null, + ServiceCategory: null, + Unit: null, + UnitPrice: -5.00m, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice"); + } + + [Fact(DisplayName = "PR5: Invalid ServiceCategory enum fails")] + public void InvalidServiceCategory_Fails() + { + var request = new UpdatePricingLibraryItemRequest( + Title: null, + Description: null, + ServiceCategory: (ServiceCategory)999, + Unit: null, + UnitPrice: null, + Keywords: null + ); + + var result = _sut.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory"); + } +} diff --git a/lambdas/library-ingest/app.py b/lambdas/library-ingest/app.py index 796ad68..449c06f 100644 --- a/lambdas/library-ingest/app.py +++ b/lambdas/library-ingest/app.py @@ -1,8 +1,12 @@ """Proposal System - Library Ingest Lambda. -Processes approved/sent proposals into the Bedrock Knowledge Base library. -Formats proposal data as structured markdown and uploads to the library bucket, -then triggers a KB sync. +Processes approved/sent proposals and curated pricing library items into the +Bedrock Knowledge Base library. Formats data as structured markdown and uploads +to the library bucket, then triggers a KB sync. + +Supports two SQS message shapes (PR5): + - Proposal: {"jobType":"library-ingest","payload":{"proposalId":""}} + - Pricing item: {"jobType":"library-ingest","payload":{"pricingLibraryItemId":""}} """ import json @@ -101,8 +105,24 @@ def handler(event, context): try: payload = body.get("payload", body) - proposal_id = payload["proposalId"] - process_ingestion(proposal_id) + + # PR5: branch on payload shape — pricing item vs proposal. + pricing_item_id = payload.get("pricingLibraryItemId") + proposal_id = payload.get("proposalId") + if pricing_item_id and proposal_id: + logger.warning( + "Record %s has both pricingLibraryItemId and proposalId; " + "treating as a pricing item", + record.get("messageId"), + ) + if pricing_item_id: + process_pricing_item_ingestion(pricing_item_id) + elif proposal_id: + process_ingestion(proposal_id) + else: + raise ValueError( + "library-ingest payload has neither pricingLibraryItemId nor proposalId" + ) except Exception as e: # Fix: LAM-M5 — include stack trace in error logging logger.exception( @@ -128,6 +148,104 @@ def process_ingestion(proposal_id: str): trigger_kb_sync() +def process_pricing_item_ingestion(item_id: str): + """PR5: Ingest a curated pricing library item into the Bedrock Knowledge Base.""" + item = fetch_pricing_item(item_id) + if not item: + logger.warning("Pricing library item %s not found, skipping", item_id) + return + + document = format_pricing_item_document(item) + + s3_key = upload_pricing_item_to_library(item, document) + + if s3_key: + trigger_kb_sync() + + +def fetch_pricing_item(item_id: str) -> dict | None: + """PR5: Fetch a pricing library item from the .NET API.""" + try: + resp = _retry_request( + "GET", + f"{API_BASE_URL}/api/pricing-library/{item_id}", + headers=_api_headers(), + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + logger.exception("Error fetching pricing library item: %s", e) + return None + + +def format_pricing_item_document(item: dict) -> str: + """PR5: Format a pricing library item as a markdown document for Bedrock KB embedding.""" + unit_price = item.get("unitPrice") + price_str = f"${unit_price:,.2f}" if unit_price is not None else "-" + + keywords = item.get("keywords") or [] + keywords_str = ", ".join(keywords) if isinstance(keywords, list) else str(keywords) + + lines = [ + f"# Pricing Item: {item.get('title', '')}", + "", + f"**Service Category:** {item.get('serviceCategory', '')}", + f"**Unit:** {item.get('unit', '')}", + f"**Unit Price:** {price_str}", + f"**Source:** {item.get('source', '')}", + "", + "## Description", + "", + item.get("description", ""), + "", + "## Keywords", + "", + keywords_str, + ] + + return "\n".join(lines) + + +def upload_pricing_item_to_library(item: dict, document: str) -> str | None: + """PR5: Upload a formatted pricing item to the library S3 bucket. + + Uses the ``pricing-library/{category}/{item_id}.md`` key prefix so pricing + items never collide with proposal documents stored under ``proposals/``. + """ + if not LIBRARY_BUCKET: + logger.warning("No library bucket configured") + return None + + item_id = item.get("id", "unknown") + title = item.get("title", "unknown") + category = item.get("serviceCategory", "general") + s3_key = f"pricing-library/{category.lower()}/{item_id}.md" + + try: + s3_key = _validate_s3_key(s3_key) + except ValueError as e: + logger.error("Invalid S3 key for pricing item %s: %s", item_id, e) + return None + + try: + s3.put_object( + Bucket=LIBRARY_BUCKET, + Key=s3_key, + Body=document.encode("utf-8"), + ContentType="text/markdown", + Metadata={ + "service-category": category, + "pricing-item-title": title, + "pricing-item-id": str(item_id), + }, + ) + logger.info("Uploaded pricing item %s to library bucket at %s", item_id, s3_key) + return s3_key + except Exception as e: + logger.exception("Error uploading pricing item to library: %s", e) + return None + + def fetch_proposal(proposal_id: str) -> dict | None: try: resp = _retry_request( diff --git a/lambdas/tests/test_library_ingest.py b/lambdas/tests/test_library_ingest.py new file mode 100644 index 0000000..1c81ba7 --- /dev/null +++ b/lambdas/tests/test_library_ingest.py @@ -0,0 +1,235 @@ +"""Tests for library-ingest Lambda handler. + +PR5: Covers both the existing proposal ingestion path and the new pricing +library item ingestion path. +""" + +import importlib +import sys +import os +from unittest.mock import patch + +from helpers import make_sqs_event + +# Import library-ingest app under a unique module name (the directory name +# contains a hyphen so normal import won't work). +_library_ingest_dir = os.path.join(os.path.dirname(__file__), "..", "library-ingest") +_spec = importlib.util.spec_from_file_location( + "library_ingest_app", os.path.join(_library_ingest_dir, "app.py") +) +library_ingest_app = importlib.util.module_from_spec(_spec) +sys.modules["library_ingest_app"] = library_ingest_app +_spec.loader.exec_module(library_ingest_app) + + +# --------------------------------------------------------------------------- +# Sample data +# --------------------------------------------------------------------------- + +SAMPLE_PRICING_ITEM = { + "id": "item-abc-123", + "title": "Standard HVAC Filter Replacement", + "serviceCategory": "HVAC", + "unit": "each", + "unitPrice": 85.50, + "description": "Replace standard 20x25 HVAC filter with MERV-13 rated filter.", + "keywords": ["hvac", "filter", "replacement", "merv-13"], + "source": "PricingLibrary", +} + + +# --------------------------------------------------------------------------- +# Handler routing tests +# --------------------------------------------------------------------------- + + +class TestHandlerRouting: + """Verify the handler correctly routes pricing-item vs proposal messages.""" + + @patch.object(library_ingest_app, "process_pricing_item_ingestion") + def test_handler_routes_pricing_item_message(self, mock_process): + """PR5: pricingLibraryItemId in payload routes to pricing item path.""" + event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-abc-123"}}) + result = library_ingest_app.handler(event, None) + + mock_process.assert_called_once_with("item-abc-123") + assert result["batchItemFailures"] == [] + + @patch.object(library_ingest_app, "process_ingestion") + def test_handler_routes_proposal_message(self, mock_process): + """PR5: proposalId in payload still routes to existing proposal path.""" + event = make_sqs_event({"payload": {"proposalId": "prop-xyz-789"}}) + result = library_ingest_app.handler(event, None) + + mock_process.assert_called_once_with("prop-xyz-789") + assert result["batchItemFailures"] == [] + + @patch.object(library_ingest_app, "process_pricing_item_ingestion") + def test_handler_pricing_item_failure_goes_to_batch_failures(self, mock_process): + """PR5: Pricing item processing failure adds messageId to batchItemFailures.""" + mock_process.side_effect = RuntimeError("API unreachable") + event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-fail"}}) + result = library_ingest_app.handler(event, None) + + assert len(result["batchItemFailures"]) == 1 + assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-0" + + @patch.object(library_ingest_app, "process_pricing_item_ingestion") + @patch.object(library_ingest_app, "process_ingestion") + def test_handler_mixed_batch(self, mock_proposal, mock_pricing): + """PR5: A batch with both message types routes each correctly.""" + event = make_sqs_event( + {"payload": {"proposalId": "prop-1"}}, + {"payload": {"pricingLibraryItemId": "item-2"}}, + ) + result = library_ingest_app.handler(event, None) + + mock_proposal.assert_called_once_with("prop-1") + mock_pricing.assert_called_once_with("item-2") + assert result["batchItemFailures"] == [] + + +# --------------------------------------------------------------------------- +# Pricing item ingestion flow +# --------------------------------------------------------------------------- + + +class TestProcessPricingItemIngestion: + """Test the full pricing item ingestion pipeline.""" + + @patch.object(library_ingest_app, "trigger_kb_sync") + @patch.object(library_ingest_app, "upload_pricing_item_to_library") + @patch.object(library_ingest_app, "fetch_pricing_item") + def test_full_flow(self, mock_fetch, mock_upload, mock_sync): + """PR5: Happy path fetches item, formats, uploads, and triggers sync.""" + mock_fetch.return_value = SAMPLE_PRICING_ITEM + mock_upload.return_value = "pricing-library/hvac/item-abc-123.md" + + library_ingest_app.process_pricing_item_ingestion("item-abc-123") + + mock_fetch.assert_called_once_with("item-abc-123") + mock_upload.assert_called_once() + mock_sync.assert_called_once() + + @patch.object(library_ingest_app, "trigger_kb_sync") + @patch.object(library_ingest_app, "upload_pricing_item_to_library") + @patch.object(library_ingest_app, "fetch_pricing_item") + def test_skips_when_item_not_found(self, mock_fetch, mock_upload, mock_sync): + """PR5: Missing item logs warning and returns without failing the batch.""" + mock_fetch.return_value = None + + library_ingest_app.process_pricing_item_ingestion("nonexistent") + + mock_upload.assert_not_called() + mock_sync.assert_not_called() + + @patch.object(library_ingest_app, "trigger_kb_sync") + @patch.object(library_ingest_app, "upload_pricing_item_to_library") + @patch.object(library_ingest_app, "fetch_pricing_item") + def test_skips_sync_when_upload_fails(self, mock_fetch, mock_upload, mock_sync): + """PR5: No KB sync when S3 upload returns None.""" + mock_fetch.return_value = SAMPLE_PRICING_ITEM + mock_upload.return_value = None + + library_ingest_app.process_pricing_item_ingestion("item-abc-123") + + mock_upload.assert_called_once() + mock_sync.assert_not_called() + + +# --------------------------------------------------------------------------- +# format_pricing_item_document +# --------------------------------------------------------------------------- + + +class TestFormatPricingItemDocument: + """Test markdown formatting for pricing items.""" + + def test_format_includes_all_fields(self): + """PR5: All pricing item fields appear in the formatted document.""" + doc = library_ingest_app.format_pricing_item_document(SAMPLE_PRICING_ITEM) + + assert "# Pricing Item: Standard HVAC Filter Replacement" in doc + assert "**Service Category:** HVAC" in doc + assert "**Unit:** each" in doc + assert "**Unit Price:** $85.50" in doc + assert "**Source:** PricingLibrary" in doc + assert "Replace standard 20x25 HVAC filter" in doc + assert "hvac, filter, replacement, merv-13" in doc + + def test_format_handles_missing_unit_price(self): + """PR5: None unitPrice renders as dash.""" + item = {**SAMPLE_PRICING_ITEM, "unitPrice": None} + doc = library_ingest_app.format_pricing_item_document(item) + + assert "**Unit Price:** -" in doc + + def test_format_handles_empty_keywords(self): + """PR5: Empty keywords list produces empty keywords section.""" + item = {**SAMPLE_PRICING_ITEM, "keywords": []} + doc = library_ingest_app.format_pricing_item_document(item) + + # Keywords section header exists, value is empty string + assert "## Keywords" in doc + + def test_format_handles_string_keywords(self): + """PR5: String keywords (non-list) are rendered as-is.""" + item = {**SAMPLE_PRICING_ITEM, "keywords": "hvac, filters"} + doc = library_ingest_app.format_pricing_item_document(item) + + assert "hvac, filters" in doc + + +# --------------------------------------------------------------------------- +# upload_pricing_item_to_library +# --------------------------------------------------------------------------- + + +class TestUploadPricingItemToLibrary: + """Test S3 upload for pricing items.""" + + @patch.object(library_ingest_app, "s3") + def test_upload_uses_pricing_library_prefix(self, mock_s3): + """PR5: S3 key uses pricing-library/ prefix, not proposals/.""" + original_bucket = library_ingest_app.LIBRARY_BUCKET + library_ingest_app.LIBRARY_BUCKET = "test-library-bucket" + try: + result = library_ingest_app.upload_pricing_item_to_library( + SAMPLE_PRICING_ITEM, "# Test doc" + ) + + assert result == "pricing-library/hvac/item-abc-123.md" + mock_s3.put_object.assert_called_once() + call_kwargs = mock_s3.put_object.call_args[1] + assert call_kwargs["Key"] == "pricing-library/hvac/item-abc-123.md" + assert call_kwargs["Bucket"] == "test-library-bucket" + assert call_kwargs["ContentType"] == "text/markdown" + finally: + library_ingest_app.LIBRARY_BUCKET = original_bucket + + def test_upload_returns_none_when_no_bucket(self): + """PR5: Returns None when LIBRARY_BUCKET is not configured.""" + original_bucket = library_ingest_app.LIBRARY_BUCKET + library_ingest_app.LIBRARY_BUCKET = "" + try: + result = library_ingest_app.upload_pricing_item_to_library( + SAMPLE_PRICING_ITEM, "# Test doc" + ) + assert result is None + finally: + library_ingest_app.LIBRARY_BUCKET = original_bucket + + @patch.object(library_ingest_app, "s3") + def test_upload_lowercases_category(self, mock_s3): + """PR5: Category in S3 key is lowercased.""" + original_bucket = library_ingest_app.LIBRARY_BUCKET + library_ingest_app.LIBRARY_BUCKET = "test-bucket" + item = {**SAMPLE_PRICING_ITEM, "serviceCategory": "Plumbing"} + try: + result = library_ingest_app.upload_pricing_item_to_library( + item, "# Test doc" + ) + assert result is not None + assert "pricing-library/plumbing/" in result + finally: + library_ingest_app.LIBRARY_BUCKET = original_bucket diff --git a/shared/api-contracts/package-lock.json b/shared/api-contracts/package-lock.json new file mode 100644 index 0000000..8b30acd --- /dev/null +++ b/shared/api-contracts/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "devDependencies": { + "typescript": "~5.7.0" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts index 79de918..6a14010 100644 --- a/shared/api-contracts/src/index.ts +++ b/shared/api-contracts/src/index.ts @@ -87,3 +87,36 @@ export interface PagedResponse { page: number; pageSize: number; } + +// PR5: Pricing Library types +export interface PricingLibraryItem { + id: string; + title: string; + description: string | null; + serviceCategory: ServiceCategory; + unit: string | null; + unitPrice: number | null; + keywords: string | null; + source: string; + createdAt: string; + updatedAt: string; +} + +export interface CreatePricingLibraryItemRequest { + title: string; + description?: string; + serviceCategory: ServiceCategory; + unit?: string; + unitPrice?: number; + keywords?: string; + source?: string; +} + +export interface UpdatePricingLibraryItemRequest { + title?: string; + description?: string; + serviceCategory?: ServiceCategory; + unit?: string; + unitPrice?: number; + keywords?: string; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index e9b92ca..94a14ad 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import ProposalListPage from './pages/proposals/list/ProposalListPage'; import AdminDashboard from './pages/admin/dashboard/AdminDashboard'; import AdminWorkspace from './pages/admin/workspace/AdminWorkspace'; import CustomerManagementPage from './pages/admin/customers/CustomerManagementPage'; +import PricingLibraryPage from './pages/admin/pricing-library/PricingLibraryPage'; export default function App() { return ( @@ -48,6 +49,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 5a985ec..086e282 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -20,6 +20,7 @@ import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import AssignmentIcon from '@mui/icons-material/Assignment'; import PeopleIcon from '@mui/icons-material/People'; import BusinessIcon from '@mui/icons-material/Business'; +import MenuBookIcon from '@mui/icons-material/MenuBook'; import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice'; import { selectUser } from '../app/slices/authSlice'; import type { RootState } from '../app/store'; @@ -37,6 +38,7 @@ const adminNav = [ { label: 'Admin Queue', path: '/admin', icon: }, { label: 'All Proposals', path: '/admin/proposals', icon: }, { label: 'Customers', path: '/admin/customers', icon: }, + { label: 'Pricing Library', path: '/admin/pricing-library', icon: }, ]; const sysadminNav = [ diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 8f8a824..943ec10 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -7,4 +7,5 @@ export const QUERY_KEYS = { users: 'users', dashboard: 'dashboard', auditTrail: 'auditTrail', + pricingLibraryList: 'pricingLibraryList', } as const; diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/lib/api/pricingLibrary.ts new file mode 100644 index 0000000..d477779 --- /dev/null +++ b/web/src/lib/api/pricingLibrary.ts @@ -0,0 +1,72 @@ +import apiClient from './client'; + +export interface PricingLibraryItem { + id: string; + title: string; + description?: string | null; + serviceCategory: string; + unit?: string | null; + unitPrice?: number | null; + keywords?: string | null; + source: string; + createdAt: string; + updatedAt: string; +} + +export interface PricingLibraryListParams { + page?: number; + pageSize?: number; +} + +export interface PagedPricingLibraryResponse { + items: PricingLibraryItem[]; + totalCount: number; + page: number; + pageSize: number; +} + +export interface CreatePricingLibraryItemRequest { + title: string; + serviceCategory: string; + description?: string; + unit?: string; + unitPrice?: number | null; + keywords?: string; +} + +export interface UpdatePricingLibraryItemRequest { + title: string; + serviceCategory: string; + description?: string; + unit?: string; + unitPrice?: number | null; + keywords?: string; +} + +export const pricingLibraryApi = { + /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ + list: async (params: PricingLibraryListParams = {}): Promise => { + const qs = new URLSearchParams(); + if (params.page) qs.append('page', String(params.page)); + if (params.pageSize) qs.append('pageSize', String(params.pageSize)); + const res = await apiClient.get(`/pricing-library/list?${qs.toString()}`); + return res.data; + }, + + /** POST /api/pricing-library — create a new pricing library item. */ + create: async (data: CreatePricingLibraryItemRequest): Promise => { + const res = await apiClient.post('/pricing-library', data); + return res.data; + }, + + /** PUT /api/pricing-library/{id} — update an existing pricing library item. */ + update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise => { + const res = await apiClient.put(`/pricing-library/${id}`, data); + return res.data; + }, + + /** DELETE /api/pricing-library/{id} — delete a pricing library item. */ + remove: async (id: string): Promise => { + await apiClient.delete(`/pricing-library/${id}`); + }, +}; diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx new file mode 100644 index 0000000..95feec0 --- /dev/null +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -0,0 +1,479 @@ +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { + Box, + Typography, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + TextField, + Button, + Skeleton, + IconButton, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Alert, + Chip, + MenuItem, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { toast } from 'react-toastify'; +import { + pricingLibraryApi, + type PricingLibraryItem, + type PagedPricingLibraryResponse, + type CreatePricingLibraryItemRequest, + type UpdatePricingLibraryItemRequest, +} from '../../../lib/api/pricingLibrary'; +import { queryClient } from '../../../lib/queryClient'; +import { QUERY_KEYS } from '../../../constants/queryKeys'; +import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; +import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; + +const COL_COUNT = 6; // Title, Category, Unit, Unit Price, Source, Actions + +/** Chip color mapping for service categories. */ +const CATEGORY_CHIP_STYLES: Record = { + HVAC: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' }, + Plumbing: { bgcolor: '#DBEAFE', color: '#1E40AF', border: '1px solid #BFDBFE' }, + Electrical: { bgcolor: '#FEF9C3', color: '#854D0E', border: '1px solid #FDE68A' }, + General: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' }, + Renovation: { bgcolor: '#F3E8FF', color: '#6B21A8', border: '1px solid #C084FC' }, + Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' }, +}; + +interface ItemFormState { + title: string; + serviceCategory: string; + unit: string; + unitPrice: string; + keywords: string; + description: string; +} + +const EMPTY_FORM: ItemFormState = { + title: '', + serviceCategory: '', + unit: '', + unitPrice: '', + keywords: '', + description: '', +}; + +/** Format a number as $X.XX or return "—" for null/undefined. */ +function formatPrice(value: number | null | undefined): string { + if (value == null) return '—'; + return `$${value.toFixed(2)}`; +} + +export default function PricingLibraryPage() { + useDocumentTitle('Pricing Library'); + + // --- List state --- + const [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const { + data, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize], + queryFn: () => pricingLibraryApi.list({ page, pageSize }), + }); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + // --- Create/Edit dialog state --- + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formErrors, setFormErrors] = useState>>({}); + + const isEditing = editingItem !== null; + + const openCreateDialog = () => { + setEditingItem(null); + setForm(EMPTY_FORM); + setFormErrors({}); + setDialogOpen(true); + }; + + const openEditDialog = (item: PricingLibraryItem) => { + setEditingItem(item); + setForm({ + title: item.title, + serviceCategory: item.serviceCategory, + unit: item.unit ?? '', + unitPrice: item.unitPrice != null ? String(item.unitPrice) : '', + keywords: item.keywords ?? '', + description: item.description ?? '', + }); + setFormErrors({}); + setDialogOpen(true); + }; + + const closeDialog = () => { + setDialogOpen(false); + setEditingItem(null); + setForm(EMPTY_FORM); + setFormErrors({}); + }; + + const validateForm = (): boolean => { + const errors: Partial> = {}; + if (!form.title.trim()) errors.title = 'Title is required'; + if (!form.serviceCategory) errors.serviceCategory = 'Service category is required'; + if (form.unitPrice.trim() !== '') { + const parsed = parseFloat(form.unitPrice); + if (isNaN(parsed) || parsed < 0) { + errors.unitPrice = 'Must be a number >= 0'; + } + } + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleFieldChange = (field: keyof ItemFormState, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + if (formErrors[field]) { + setFormErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + // --- Delete confirm dialog state --- + const [deleteTarget, setDeleteTarget] = useState(null); + + const openDeleteDialog = (item: PricingLibraryItem) => { + setDeleteTarget(item); + }; + + const closeDeleteDialog = () => { + setDeleteTarget(null); + }; + + // --- Mutations --- + const createMutation = useMutation({ + mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); + closeDialog(); + toast.success('Pricing item created'); + }, + onError: (error: Error) => { + toast.error(`Failed to create item: ${error.message}`); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => + pricingLibraryApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); + closeDialog(); + toast.success('Pricing item updated'); + }, + onError: (error: Error) => { + toast.error(`Failed to update item: ${error.message}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => pricingLibraryApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); + closeDeleteDialog(); + toast.success('Pricing item deleted'); + }, + onError: (error: Error) => { + toast.error(`Failed to delete item: ${error.message}`); + }, + }); + + const handleSubmit = () => { + if (!validateForm()) return; + + const payload = { + title: form.title.trim(), + serviceCategory: form.serviceCategory, + ...(form.unit.trim() ? { unit: form.unit.trim() } : {}), + unitPrice: form.unitPrice.trim() !== '' ? parseFloat(form.unitPrice) : null, + ...(form.keywords.trim() ? { keywords: form.keywords.trim() } : {}), + ...(form.description.trim() ? { description: form.description.trim() } : {}), + }; + + if (isEditing) { + updateMutation.mutate({ id: editingItem.id, data: payload }); + } else { + createMutation.mutate(payload); + } + }; + + const handleDeleteConfirm = () => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget.id); + } + }; + + const isSaving = createMutation.isPending || updateMutation.isPending; + + return ( + + + Pricing Library + + + + + + {/* Error state */} + {isError && ( + } + action={ + + } + sx={{ mb: 2 }} + > + Failed to load pricing library. Please try again. + + )} + + {/* Table */} + + + + + Title + Category + Unit + Unit Price + Source + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: COL_COUNT }).map((_, j) => ( + + + + ))} + + )) + : rows.map((item) => { + const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' }; + const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip; + return ( + + {item.title} + + + + {item.unit ?? '—'} + + {formatPrice(item.unitPrice)} + + {item.source} + + + + openEditDialog(item)} + > + + + + + openDeleteDialog(item)} + color="error" + > + + + + + + + ); + })} + {!isLoading && rows.length === 0 && !isError && ( + + + + No pricing items yet. Click "New Item" to add one. + + + + )} + +
+
+ + setPage(newPage + 1)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))} + rowsPerPageOptions={[12, 24, 48]} + /> +
+
+ + {/* Create / Edit Dialog */} + + {isEditing ? 'Edit Pricing Item' : 'New Pricing Item'} + + + handleFieldChange('title', e.target.value)} + error={!!formErrors.title} + helperText={formErrors.title} + size="small" + required + fullWidth + autoFocus + /> + handleFieldChange('serviceCategory', e.target.value)} + error={!!formErrors.serviceCategory} + helperText={formErrors.serviceCategory} + size="small" + required + fullWidth + select + > + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + handleFieldChange('unit', e.target.value)} + helperText="e.g., each, hour, sq ft" + size="small" + fullWidth + /> + handleFieldChange('unitPrice', e.target.value)} + error={!!formErrors.unitPrice} + helperText={formErrors.unitPrice || 'Optional. Leave blank if price varies.'} + size="small" + fullWidth + slotProps={{ + htmlInput: { min: 0, step: '0.01' }, + }} + /> + handleFieldChange('keywords', e.target.value)} + helperText="Comma-separated keywords for AI matching." + size="small" + fullWidth + multiline + minRows={2} + /> + handleFieldChange('description', e.target.value)} + size="small" + fullWidth + multiline + minRows={2} + /> + + + + + + + + + {/* Delete Confirm Dialog */} + + Delete Pricing Item + + + Are you sure you want to delete {deleteTarget?.title}? This action cannot be undone. + + + + + + + +
+ ); +}