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<IEmailService, SesEmailService>();
} }
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>(); builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
builder.Services.AddScoped<IPricingLibraryService, PricingLibraryService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl)) 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"); 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 => modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View file

@ -17,6 +17,7 @@ public class ProposalDbContext : DbContext
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<SimilarProposalReference> SimilarProposalReferences => Set<SimilarProposalReference>(); public DbSet<SimilarProposalReference> SimilarProposalReferences => Set<SimilarProposalReference>();
public DbSet<GeneratedPdf> GeneratedPdfs => Set<GeneratedPdf>(); public DbSet<GeneratedPdf> GeneratedPdfs => Set<GeneratedPdf>();
public DbSet<PricingLibraryItem> PricingLibraryItems => Set<PricingLibraryItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@ -92,5 +93,13 @@ public class ProposalDbContext : DbContext
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade); 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(GeneratedPdfsController))]
[InlineData(typeof(FilesController))] [InlineData(typeof(FilesController))]
[InlineData(typeof(VendorProposalsController))] [InlineData(typeof(VendorProposalsController))]
[InlineData(typeof(PricingLibraryController))]
public void AllControllers_HaveAuthorizeAttribute(Type controllerType) public void AllControllers_HaveAuthorizeAttribute(Type controllerType)
{ {
var attr = controllerType.GetCustomAttribute<AuthorizeAttribute>(); var attr = controllerType.GetCustomAttribute<AuthorizeAttribute>();
@ -180,4 +181,47 @@ public class AuthorizationAttributeTests
} }
#endregion #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. """Proposal System - Library Ingest Lambda.
Processes approved/sent proposals into the Bedrock Knowledge Base library. Processes approved/sent proposals and curated pricing library items into the
Formats proposal data as structured markdown and uploads to the library bucket, Bedrock Knowledge Base library. Formats data as structured markdown and uploads
then triggers a KB sync. 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 import json
@ -101,8 +105,24 @@ def handler(event, context):
try: try:
payload = body.get("payload", body) 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: except Exception as e:
# Fix: LAM-M5 — include stack trace in error logging # Fix: LAM-M5 — include stack trace in error logging
logger.exception( logger.exception(
@ -128,6 +148,104 @@ def process_ingestion(proposal_id: str):
trigger_kb_sync() 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: def fetch_proposal(proposal_id: str) -> dict | None:
try: try:
resp = _retry_request( 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; page: number;
pageSize: 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 AdminDashboard from './pages/admin/dashboard/AdminDashboard';
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace'; import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
import CustomerManagementPage from './pages/admin/customers/CustomerManagementPage'; import CustomerManagementPage from './pages/admin/customers/CustomerManagementPage';
import PricingLibraryPage from './pages/admin/pricing-library/PricingLibraryPage';
export default function App() { export default function App() {
return ( return (
@ -48,6 +49,7 @@ export default function App() {
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} /> <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/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
<Route path="/admin/customers" element={<RoleGuard roles={['Admin', 'SysAdmin']}><CustomerManagementPage /></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={ <Route path="/admin/users" element={
<RoleGuard roles={['SysAdmin']}> <RoleGuard roles={['SysAdmin']}>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}> <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 AssignmentIcon from '@mui/icons-material/Assignment';
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from '@mui/icons-material/People';
import BusinessIcon from '@mui/icons-material/Business'; import BusinessIcon from '@mui/icons-material/Business';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice'; import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice';
import { selectUser } from '../app/slices/authSlice'; import { selectUser } from '../app/slices/authSlice';
import type { RootState } from '../app/store'; import type { RootState } from '../app/store';
@ -37,6 +38,7 @@ const adminNav = [
{ label: 'Admin Queue', path: '/admin', icon: <AdminPanelSettingsIcon fontSize="small" /> }, { label: 'Admin Queue', path: '/admin', icon: <AdminPanelSettingsIcon fontSize="small" /> },
{ label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon fontSize="small" /> }, { label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon fontSize="small" /> },
{ label: 'Customers', path: '/admin/customers', icon: <BusinessIcon fontSize="small" /> }, { label: 'Customers', path: '/admin/customers', icon: <BusinessIcon fontSize="small" /> },
{ label: 'Pricing Library', path: '/admin/pricing-library', icon: <MenuBookIcon fontSize="small" /> },
]; ];
const sysadminNav = [ const sysadminNav = [

View file

@ -7,4 +7,5 @@ export const QUERY_KEYS = {
users: 'users', users: 'users',
dashboard: 'dashboard', dashboard: 'dashboard',
auditTrail: 'auditTrail', auditTrail: 'auditTrail',
pricingLibraryList: 'pricingLibraryList',
} as const; } 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>
);
}