feat: pricing library — curated priced items feed the RAG corpus (#127)
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run

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.
This commit is contained in:
Adam Moussa 2026-06-18 12:49:47 -04:00 • committed by GitHub
parent 1fca0fa978
commit 5d84399a0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2768 additions and 5 deletions

View file

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

View file

@ -137,6 +137,7 @@ else
builder.Services.AddScoped<IEmailService, SesEmailService>();
}
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
builder.Services.AddScoped<IPricingLibraryService, PricingLibraryService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))
{

View file

@ -0,0 +1,38 @@
using ProposalSystem.Domain.Entities;
namespace ProposalSystem.Application.DTOs;
/// <summary>
/// PR5: DTOs for the Pricing Library CRUD endpoints.
/// </summary>
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
);

View file

@ -0,0 +1,15 @@
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Interfaces;
/// <summary>
/// PR5: Service interface for Pricing Library CRUD operations.
/// </summary>
public interface IPricingLibraryService
{
Task<PagedResponse<PricingLibraryItemResponse>> ListAsync(int page, int pageSize, CancellationToken ct = default);
Task<PricingLibraryItemResponse?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<PricingLibraryItemResponse> CreateAsync(CreatePricingLibraryItemRequest request, CancellationToken ct = default);
Task<PricingLibraryItemResponse?> UpdateAsync(Guid id, UpdatePricingLibraryItemRequest request, CancellationToken ct = default);
Task<bool> DeleteAsync(Guid id, CancellationToken ct = default);
}

View file

@ -0,0 +1,39 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
/// <summary>
/// PR5: Validates CreatePricingLibraryItemRequest payloads.
/// </summary>
public class CreatePricingLibraryItemValidator : AbstractValidator<CreatePricingLibraryItemRequest>
{
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);
}
}

View file

@ -0,0 +1,38 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
/// <summary>
/// PR5: Validates UpdatePricingLibraryItemRequest payloads.
/// </summary>
public class UpdatePricingLibraryItemValidator : AbstractValidator<UpdatePricingLibraryItemRequest>
{
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);
}
}

View file

@ -0,0 +1,19 @@
namespace ProposalSystem.Domain.Entities;
/// <summary>
/// PR5: Reusable priced reference item for the Bedrock RAG pricing library.
/// Admins seed/manage these to enrich the corpus beyond Sent proposals.
/// </summary>
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; }
}

View file

@ -0,0 +1,552 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Details")
.HasColumnType("jsonb");
b.Property<string>("IpAddress")
.HasColumnType("text");
b.Property<Guid?>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Addresses")
.HasColumnType("jsonb");
b.Property<string>("ContactEmail")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Customers");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("GeneratedById")
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PricingMode")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("Unit")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.ToTable("LineItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Keywords")
.HasColumnType("text");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Unit")
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("PricingLibraryItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ApprovedById")
.HasColumnType("uuid");
b.Property<Guid?>("AssignedAdminId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentRevision")
.HasColumnType("integer");
b.Property<string>("CustomerAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentProposalId")
.HasColumnType("uuid");
b.Property<string>("PoNumber")
.HasColumnType("text");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProposalNumber")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RefinedScope")
.HasColumnType("text");
b.Property<string>("ScopeOfWork")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SubmittedById")
.HasColumnType("uuid");
b.Property<decimal>("TotalBidAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("VendorTotalCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("ReferencedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ReferencedById")
.HasColumnType("uuid");
b.Property<string>("ReferencedLibraryItemId")
.IsRequired()
.HasColumnType("text");
b.Property<float>("SimilarityScore")
.HasColumnType("real");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.HasIndex("ReferencedById");
b.ToTable("SimilarProposalReferences");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CognitoSub")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ExtractedData")
.HasColumnType("jsonb");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProcessingStatus")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<string>("S3Key")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalVendorCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View file

@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProposalSystem.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPricingLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PricingLibraryItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
ServiceCategory = table.Column<string>(type: "text", nullable: false),
Unit = table.Column<string>(type: "text", nullable: true),
UnitPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
Keywords = table.Column<string>(type: "text", nullable: true),
Source = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PricingLibraryItems", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PricingLibraryItems");
}
}
}

View file

@ -169,6 +169,48 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.ToTable("LineItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Keywords")
.HasColumnType("text");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Unit")
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("PricingLibraryItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Property<Guid>("Id")

View file

@ -17,6 +17,7 @@ public class ProposalDbContext : DbContext
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<SimilarProposalReference> SimilarProposalReferences => Set<SimilarProposalReference>();
public DbSet<GeneratedPdf> GeneratedPdfs => Set<GeneratedPdf>();
public DbSet<PricingLibraryItem> PricingLibraryItems => Set<PricingLibraryItem>();
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<PricingLibraryItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.UnitPrice).HasPrecision(18, 2);
entity.Property(e => e.ServiceCategory).HasConversion<string>();
});
}
}

View file

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

View file

@ -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<AuthorizeAttribute>();
@ -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<AuthorizeAttribute>()
.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<AuthorizeAttribute>();
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<AuthorizeAttribute>();
attr.Should().NotBeNull();
attr!.Roles.Should().Contain("admins",
"GetById must be reachable by the internal API key middleware which assigns the 'admins' role");
}
#endregion
}

View file

@ -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;
/// <summary>
/// PR5: Tests for PricingLibraryService. Verifies CRUD behavior and that
/// create/update publish "library-ingest" SQS jobs for KB sync.
/// </summary>
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<IJobPublisher>();
_sut = new PricingLibraryService(
_db,
_jobPublisher,
Substitute.For<ILogger<PricingLibraryService>>());
}
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<object>(o => o.ToString()!.Contains(result.Id.ToString())),
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR5: Create succeeds even when job publish fails (no rollback)")]
public async Task CreateAsync_JobPublishFails_DoesNotRollBack()
{
_jobPublisher.PublishAsync(Arg.Any<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>())
.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<object>(o => o.ToString()!.Contains(item.Id.ToString())),
Arg.Any<CancellationToken>());
}
[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<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>())
.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
}

View file

@ -0,0 +1,239 @@
using FluentAssertions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using ProposalSystem.Domain.Entities;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// PR5: Tests for CreatePricingLibraryItemValidator.
/// </summary>
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();
}
}

View file

@ -0,0 +1,121 @@
using FluentAssertions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using ProposalSystem.Domain.Entities;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// PR5: Tests for UpdatePricingLibraryItemValidator.
/// </summary>
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");
}
}

View file

@ -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":"<guid>"}}
- Pricing item: {"jobType":"library-ingest","payload":{"pricingLibraryItemId":"<guid>"}}
"""
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(

View file

@ -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

29
shared/api-contracts/package-lock.json generated Normal file
View file

@ -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"
}
}
}
}

View file

@ -87,3 +87,36 @@ export interface PagedResponse<T> {
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;
}

View file

@ -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() {
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
<Route path="/admin/customers" element={<RoleGuard roles={['Admin', 'SysAdmin']}><CustomerManagementPage /></RoleGuard>} />
<Route path="/admin/pricing-library" element={<RoleGuard roles={['Admin', 'SysAdmin']}><PricingLibraryPage /></RoleGuard>} />
<Route path="/admin/users" element={
<RoleGuard roles={['SysAdmin']}>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>

View file

@ -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: <AdminPanelSettingsIcon fontSize="small" /> },
{ label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon fontSize="small" /> },
{ label: 'Customers', path: '/admin/customers', icon: <BusinessIcon fontSize="small" /> },
{ label: 'Pricing Library', path: '/admin/pricing-library', icon: <MenuBookIcon fontSize="small" /> },
];
const sysadminNav = [

View file

@ -7,4 +7,5 @@ export const QUERY_KEYS = {
users: 'users',
dashboard: 'dashboard',
auditTrail: 'auditTrail',
pricingLibraryList: 'pricingLibraryList',
} as const;

View file

@ -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<PagedPricingLibraryResponse> => {
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<PricingLibraryItem> => {
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<PricingLibraryItem> => {
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<void> => {
await apiClient.delete(`/pricing-library/${id}`);
},
};

View file

@ -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<string, { bgcolor: string; color: string; border: string }> = {
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<PagedPricingLibraryResponse>({
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<PricingLibraryItem | null>(null);
const [form, setForm] = useState<ItemFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof ItemFormState, string>>>({});
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<Record<keyof ItemFormState, string>> = {};
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<PricingLibraryItem | null>(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 (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Pricing Library</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
New Item
</Button>
</Box>
<Card>
<CardContent sx={{ pb: 0 }}>
{/* Error state */}
{isError && (
<Alert
severity="error"
icon={<ErrorOutlineIcon />}
action={
<Button color="inherit" size="small" onClick={() => refetch()}>
Retry
</Button>
}
sx={{ mb: 2 }}
>
Failed to load pricing library. Please try again.
</Alert>
)}
{/* Table */}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell>Category</TableCell>
<TableCell>Unit</TableCell>
<TableCell sx={{ textAlign: 'right' }}>Unit Price</TableCell>
<TableCell>Source</TableCell>
<TableCell sx={{ width: 100 }} />
</TableRow>
</TableHead>
<TableBody>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COL_COUNT }).map((_, j) => (
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
: rows.map((item) => {
const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' };
const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip;
return (
<TableRow key={item.id} hover>
<TableCell sx={{ fontWeight: 600 }}>{item.title}</TableCell>
<TableCell>
<Chip
label={item.serviceCategory}
size="small"
sx={{
bgcolor: chipStyle.bgcolor,
color: chipStyle.color,
border: chipStyle.border,
fontWeight: 600,
fontSize: '12px',
}}
/>
</TableCell>
<TableCell>{item.unit ?? '—'}</TableCell>
<TableCell sx={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{formatPrice(item.unitPrice)}
</TableCell>
<TableCell>{item.source}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Edit item" arrow>
<IconButton
size="small"
onClick={() => openEditDialog(item)}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete item" arrow>
<IconButton
size="small"
onClick={() => openDeleteDialog(item)}
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
);
})}
{!isLoading && rows.length === 0 && !isError && (
<TableRow>
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
No pricing items yet. Click &quot;New Item&quot; to add one.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page - 1}
onPageChange={(_, newPage) => setPage(newPage + 1)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
rowsPerPageOptions={[12, 24, 48]}
/>
</CardContent>
</Card>
{/* Create / Edit Dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{isEditing ? 'Edit Pricing Item' : 'New Pricing Item'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Title"
value={form.title}
onChange={(e) => handleFieldChange('title', e.target.value)}
error={!!formErrors.title}
helperText={formErrors.title}
size="small"
required
fullWidth
autoFocus
/>
<TextField
label="Service Category"
value={form.serviceCategory}
onChange={(e) => handleFieldChange('serviceCategory', e.target.value)}
error={!!formErrors.serviceCategory}
helperText={formErrors.serviceCategory}
size="small"
required
fullWidth
select
>
{SERVICE_CATEGORIES.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>
<TextField
label="Unit"
value={form.unit}
onChange={(e) => handleFieldChange('unit', e.target.value)}
helperText="e.g., each, hour, sq ft"
size="small"
fullWidth
/>
<TextField
label="Unit Price"
type="number"
value={form.unitPrice}
onChange={(e) => 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' },
}}
/>
<TextField
label="Keywords"
value={form.keywords}
onChange={(e) => handleFieldChange('keywords', e.target.value)}
helperText="Comma-separated keywords for AI matching."
size="small"
fullWidth
multiline
minRows={2}
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
size="small"
fullWidth
multiline
minRows={2}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirm Dialog */}
<Dialog open={deleteTarget !== null} onClose={closeDeleteDialog} maxWidth="xs" fullWidth>
<DialogTitle>Delete Pricing Item</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete <strong>{deleteTarget?.title}</strong>? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closeDeleteDialog} disabled={deleteMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
color="error"
onClick={handleDeleteConfirm}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}