mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 04:13:13 +00:00
feat: pricing library — curated priced items feed the RAG corpus (#127)
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
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:
parent
1fca0fa978
commit
5d84399a0d
25 changed files with 2768 additions and 5 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs
Normal file
19
api/src/ProposalSystem.Domain/Entities/PricingLibraryItem.cs
Normal 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; }
|
||||||
|
}
|
||||||
552
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs
generated
Normal file
552
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260612225458_AddPricingLibrary.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
235
lambdas/tests/test_library_ingest.py
Normal file
235
lambdas/tests/test_library_ingest.py
Normal 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
29
shared/api-contracts/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
72
web/src/lib/api/pricingLibrary.ts
Normal file
72
web/src/lib/api/pricingLibrary.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
479
web/src/pages/admin/pricing-library/PricingLibraryPage.tsx
Normal file
479
web/src/pages/admin/pricing-library/PricingLibraryPage.tsx
Normal 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 "New Item" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue