Merge branch 'main' into fix/aurora-construct-id

This commit is contained in:
Adam Moussa 2026-06-18 12:50:10 -04:00 • committed by GitHub
commit 63e67d21e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 4664 additions and 88 deletions

View file

@ -17,7 +17,10 @@ public class CustomersController : ControllerBase
_customerService = customerService;
}
// Additive constraint: keep search at the original GET /api/customers?query=
// route so the frozen mobile app and existing web calls keep working.
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<CustomerResponse>), 200)]
public async Task<ActionResult<IReadOnlyList<CustomerResponse>>> Search(
[FromQuery] string? query,
CancellationToken ct)
@ -26,7 +29,22 @@ public class CustomersController : ControllerBase
return Ok(result);
}
// New paginated management list at a new path (additive — does not move search).
[HttpGet("list")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(PagedResponse<CustomerResponse>), 200)]
public async Task<ActionResult<PagedResponse<CustomerResponse>>> List(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 25,
CancellationToken ct = default)
{
var result = await _customerService.ListAsync(page, pageSize, ct);
return Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(CustomerResponse), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<CustomerResponse>> Create(
[FromBody] CreateCustomerRequest request,
CancellationToken ct)
@ -34,4 +52,19 @@ public class CustomersController : ControllerBase
var result = await _customerService.CreateAsync(request, ct);
return Created($"/api/customers/{result.Id}", result);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(CustomerResponse), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<CustomerResponse>> Update(
Guid id,
[FromBody] UpdateCustomerRequest request,
CancellationToken ct)
{
var result = await _customerService.UpdateAsync(id, request, ct);
if (result == null) return NotFound();
return Ok(result);
}
}

View file

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

View file

@ -2,6 +2,7 @@ using System.Text;
using Amazon.DynamoDBv2;
using Amazon.S3;
using Amazon.SecretsManager;
using Amazon.SimpleEmailV2;
using Amazon.SQS;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@ -28,6 +29,7 @@ if (!devMode)
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
builder.Services.AddAWSService<IAmazonDynamoDB>();
builder.Services.AddAWSService<IAmazonSimpleEmailServiceV2>();
}
// Database
@ -126,13 +128,16 @@ if (devMode)
{
builder.Services.AddScoped<IS3Service, DevS3Service>();
builder.Services.AddScoped<ISiteService, DevSiteService>();
builder.Services.AddScoped<IEmailService, DevEmailService>();
}
else
{
builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISiteService, SiteService>();
builder.Services.AddScoped<IEmailService, SesEmailService>();
}
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
builder.Services.AddScoped<IPricingLibraryService, PricingLibraryService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))
{
@ -240,10 +245,27 @@ app.Use(async (context, next) =>
app.MapControllers();
app.MapHealthChecks("/api/health");
// PR4: Concurrency-safe startup migrations — use a Postgres advisory lock so only one
// Lambda cold-start instance migrates at a time. Follows the same pg_advisory_xact_lock
// pattern used by ProposalNumberGenerator. The lock is released when the transaction commits.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProposalDbContext>();
db.Database.Migrate();
await using var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
await using var lockCmd = connection.CreateCommand();
lockCmd.CommandText = "SELECT pg_advisory_lock(hashtext('ef_migrations'))";
await lockCmd.ExecuteNonQueryAsync();
try
{
db.Database.Migrate();
}
finally
{
await using var unlockCmd = connection.CreateCommand();
unlockCmd.CommandText = "SELECT pg_advisory_unlock(hashtext('ef_migrations'))";
await unlockCmd.ExecuteNonQueryAsync();
}
}
app.Run();

View file

@ -0,0 +1,26 @@
using ProposalSystem.Application.Interfaces;
namespace ProposalSystem.Api.Services;
public class DevEmailService : IEmailService
{
private readonly ILogger<DevEmailService> _logger;
public DevEmailService(ILogger<DevEmailService> logger)
{
_logger = logger;
}
public Task SendProposalDeliveryAsync(
string toEmail,
string proposalNumber,
string customerName,
string pdfUrl,
CancellationToken ct = default)
{
_logger.LogInformation(
"Dev mode - skipping email delivery: proposal {ProposalNumber}, PDF link: {PdfUrl}",
proposalNumber, pdfUrl);
return Task.CompletedTask;
}
}

View file

@ -4,10 +4,18 @@ public record CustomerResponse(
Guid Id,
string Name,
List<string> Addresses,
string? ContactEmail,
DateTime CreatedAt
);
public record CreateCustomerRequest(
string Name,
List<string>? Addresses
List<string>? Addresses,
string? ContactEmail
);
public record UpdateCustomerRequest(
string? Name,
List<string>? Addresses,
string? ContactEmail
);

View file

@ -0,0 +1,38 @@
using ProposalSystem.Domain.Entities;
namespace ProposalSystem.Application.DTOs;
/// <summary>
/// PR5: DTOs for the Pricing Library CRUD endpoints.
/// </summary>
public record PricingLibraryItemResponse(
Guid Id,
string Title,
string? Description,
ServiceCategory ServiceCategory,
string? Unit,
decimal? UnitPrice,
string? Keywords,
string Source,
DateTime CreatedAt,
DateTime UpdatedAt
);
public record CreatePricingLibraryItemRequest(
string Title,
string? Description,
ServiceCategory ServiceCategory,
string? Unit,
decimal? UnitPrice,
string? Keywords,
string? Source
);
public record UpdatePricingLibraryItemRequest(
string? Title,
string? Description,
ServiceCategory? ServiceCategory,
string? Unit,
decimal? UnitPrice,
string? Keywords
);

View file

@ -5,5 +5,7 @@ namespace ProposalSystem.Application.Interfaces;
public interface ICustomerService
{
Task<IReadOnlyList<CustomerResponse>> SearchAsync(string? query, CancellationToken ct = default);
Task<PagedResponse<CustomerResponse>> ListAsync(int page, int pageSize, CancellationToken ct = default);
Task<CustomerResponse> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
Task<CustomerResponse?> UpdateAsync(Guid id, UpdateCustomerRequest request, CancellationToken ct = default);
}

View file

@ -0,0 +1,14 @@
namespace ProposalSystem.Application.Interfaces;
public interface IEmailService
{
/// <summary>
/// Sends a proposal delivery email to the customer with a link to the PDF.
/// </summary>
Task SendProposalDeliveryAsync(
string toEmail,
string proposalNumber,
string customerName,
string pdfUrl,
CancellationToken ct = default);
}

View file

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

View file

@ -10,5 +10,10 @@ public class CreateCustomerValidator : AbstractValidator<CreateCustomerRequest>
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Customer name is required")
.MaximumLength(200);
RuleFor(x => x.ContactEmail)
.EmailAddress().WithMessage("Contact email must be a valid email address")
.MaximumLength(320)
.When(x => !string.IsNullOrEmpty(x.ContactEmail));
}
}

View file

@ -0,0 +1,39 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
/// <summary>
/// PR5: Validates CreatePricingLibraryItemRequest payloads.
/// </summary>
public class CreatePricingLibraryItemValidator : AbstractValidator<CreatePricingLibraryItemRequest>
{
public CreatePricingLibraryItemValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required")
.MaximumLength(200);
RuleFor(x => x.Description)
.MaximumLength(5000)
.When(x => x.Description != null);
RuleFor(x => x.ServiceCategory).IsInEnum();
RuleFor(x => x.Unit)
.MaximumLength(50)
.When(x => x.Unit != null);
RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0).WithMessage("Unit price must be zero or positive")
.When(x => x.UnitPrice.HasValue);
RuleFor(x => x.Keywords)
.MaximumLength(2000)
.When(x => x.Keywords != null);
RuleFor(x => x.Source)
.MaximumLength(100)
.When(x => x.Source != null);
}
}

View file

@ -0,0 +1,19 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
public class UpdateCustomerValidator : AbstractValidator<UpdateCustomerRequest>
{
public UpdateCustomerValidator()
{
RuleFor(x => x.Name)
.MaximumLength(200)
.When(x => x.Name != null);
RuleFor(x => x.ContactEmail)
.EmailAddress().WithMessage("Contact email must be a valid email address")
.MaximumLength(320)
.When(x => !string.IsNullOrEmpty(x.ContactEmail));
}
}

View file

@ -0,0 +1,38 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
/// <summary>
/// PR5: Validates UpdatePricingLibraryItemRequest payloads.
/// </summary>
public class UpdatePricingLibraryItemValidator : AbstractValidator<UpdatePricingLibraryItemRequest>
{
public UpdatePricingLibraryItemValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title cannot be empty when provided")
.MaximumLength(200)
.When(x => x.Title != null);
RuleFor(x => x.Description)
.MaximumLength(5000)
.When(x => x.Description != null);
RuleFor(x => x.ServiceCategory)
.IsInEnum()
.When(x => x.ServiceCategory.HasValue);
RuleFor(x => x.Unit)
.MaximumLength(50)
.When(x => x.Unit != null);
RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0).WithMessage("Unit price must be zero or positive")
.When(x => x.UnitPrice.HasValue);
RuleFor(x => x.Keywords)
.MaximumLength(2000)
.When(x => x.Keywords != null);
}
}

View file

@ -15,7 +15,8 @@ public enum AuditAction
MarkSent,
CreateRevision,
UpdateRole,
ReturnToReview
ReturnToReview,
DeliverEmail
}
public class AuditLog

View file

@ -5,6 +5,7 @@ public class Customer
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Addresses { get; set; }
public string? ContactEmail { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View file

@ -0,0 +1,19 @@
namespace ProposalSystem.Domain.Entities;
/// <summary>
/// PR5: Reusable priced reference item for the Bedrock RAG pricing library.
/// Admins seed/manage these to enrich the corpus beyond Sent proposals.
/// </summary>
public class PricingLibraryItem
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public ServiceCategory ServiceCategory { get; set; }
public string? Unit { get; set; }
public decimal? UnitPrice { get; set; }
public string? Keywords { get; set; }
public string Source { get; set; } = "Manual";
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View file

@ -0,0 +1,510 @@
// <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("20260612222415_AddCustomerContactEmail")]
partial class AddCustomerContactEmail
{
/// <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.Proposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ApprovedById")
.HasColumnType("uuid");
b.Property<Guid?>("AssignedAdminId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentRevision")
.HasColumnType("integer");
b.Property<string>("CustomerAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentProposalId")
.HasColumnType("uuid");
b.Property<string>("PoNumber")
.HasColumnType("text");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProposalNumber")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RefinedScope")
.HasColumnType("text");
b.Property<string>("ScopeOfWork")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SubmittedById")
.HasColumnType("uuid");
b.Property<decimal>("TotalBidAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("VendorTotalCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("WorkOrderNumber")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ApprovedById");
b.HasIndex("AssignedAdminId");
b.HasIndex("ParentProposalId");
b.HasIndex("ProposalNumber")
.IsUnique();
b.HasIndex("SubmittedById");
b.ToTable("Proposals");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("ReferencedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ReferencedById")
.HasColumnType("uuid");
b.Property<string>("ReferencedLibraryItemId")
.IsRequired()
.HasColumnType("text");
b.Property<float>("SimilarityScore")
.HasColumnType("real");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.HasIndex("ReferencedById");
b.ToTable("SimilarProposalReferences");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CognitoSub")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CognitoSub")
.IsUnique();
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ExtractedData")
.HasColumnType("jsonb");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProcessingStatus")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<string>("S3Key")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalVendorCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VendorName")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.ToTable("VendorProposals");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId");
b.HasOne("ProposalSystem.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
b.Navigation("User");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy")
.WithMany()
.HasForeignKey("GeneratedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GeneratedBy");
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany("LineItems")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy")
.WithMany()
.HasForeignKey("ApprovedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin")
.WithMany()
.HasForeignKey("AssignedAdminId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal")
.WithMany()
.HasForeignKey("ParentProposalId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy")
.WithMany()
.HasForeignKey("SubmittedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ApprovedBy");
b.Navigation("AssignedAdmin");
b.Navigation("ParentProposal");
b.Navigation("SubmittedBy");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy")
.WithMany()
.HasForeignKey("ReferencedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
b.Navigation("ReferencedBy");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany("VendorProposals")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Navigation("LineItems");
b.Navigation("VendorProposals");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProposalSystem.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddCustomerContactEmail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ContactEmail",
table: "Customers",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContactEmail",
table: "Customers");
}
}
}

View file

@ -0,0 +1,552 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using ProposalSystem.Infrastructure.Data;
#nullable disable
namespace ProposalSystem.Infrastructure.Data.Migrations
{
[DbContext(typeof(ProposalDbContext))]
[Migration("20260612225458_AddPricingLibrary")]
partial class AddPricingLibrary
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.27")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Details")
.HasColumnType("jsonb");
b.Property<string>("IpAddress")
.HasColumnType("text");
b.Property<Guid?>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.HasIndex("Timestamp");
b.HasIndex("UserId");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Addresses")
.HasColumnType("jsonb");
b.Property<string>("ContactEmail")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Customers");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("GeneratedById")
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("S3Key")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GeneratedById");
b.HasIndex("ProposalId");
b.ToTable("GeneratedPdfs");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PricingMode")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("Unit")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.ToTable("LineItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Keywords")
.HasColumnType("text");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Unit")
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("PricingLibraryItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ApprovedById")
.HasColumnType("uuid");
b.Property<Guid?>("AssignedAdminId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentRevision")
.HasColumnType("integer");
b.Property<string>("CustomerAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentProposalId")
.HasColumnType("uuid");
b.Property<string>("PoNumber")
.HasColumnType("text");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProposalNumber")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RefinedScope")
.HasColumnType("text");
b.Property<string>("ScopeOfWork")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SubmittedById")
.HasColumnType("uuid");
b.Property<decimal>("TotalBidAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("VendorTotalCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("WorkOrderNumber")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ApprovedById");
b.HasIndex("AssignedAdminId");
b.HasIndex("ParentProposalId");
b.HasIndex("ProposalNumber")
.IsUnique();
b.HasIndex("SubmittedById");
b.ToTable("Proposals");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("ReferencedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ReferencedById")
.HasColumnType("uuid");
b.Property<string>("ReferencedLibraryItemId")
.IsRequired()
.HasColumnType("text");
b.Property<float>("SimilarityScore")
.HasColumnType("real");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.HasIndex("ReferencedById");
b.ToTable("SimilarProposalReferences");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CognitoSub")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CognitoSub")
.IsUnique();
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ExtractedData")
.HasColumnType("jsonb");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProcessingStatus")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<string>("S3Key")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalVendorCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VendorName")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.ToTable("VendorProposals");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId");
b.HasOne("ProposalSystem.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
b.Navigation("User");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy")
.WithMany()
.HasForeignKey("GeneratedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GeneratedBy");
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany("LineItems")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy")
.WithMany()
.HasForeignKey("ApprovedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin")
.WithMany()
.HasForeignKey("AssignedAdminId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal")
.WithMany()
.HasForeignKey("ParentProposalId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy")
.WithMany()
.HasForeignKey("SubmittedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ApprovedBy");
b.Navigation("AssignedAdmin");
b.Navigation("ParentProposal");
b.Navigation("SubmittedBy");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy")
.WithMany()
.HasForeignKey("ReferencedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
b.Navigation("ReferencedBy");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany("VendorProposals")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Navigation("LineItems");
b.Navigation("VendorProposals");
});
#pragma warning restore 612, 618
}
}
}

View file

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

View file

@ -17,7 +17,7 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("ProductVersion", "8.0.27")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -67,6 +67,9 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.Property<string>("Addresses")
.HasColumnType("jsonb");
b.Property<string>("ContactEmail")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@ -166,6 +169,48 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.ToTable("LineItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.PricingLibraryItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Keywords")
.HasColumnType("text");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Unit")
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("PricingLibraryItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Property<Guid>("Id")

View file

@ -17,6 +17,7 @@ public class ProposalDbContext : DbContext
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<SimilarProposalReference> SimilarProposalReferences => Set<SimilarProposalReference>();
public DbSet<GeneratedPdf> GeneratedPdfs => Set<GeneratedPdf>();
public DbSet<PricingLibraryItem> PricingLibraryItems => Set<PricingLibraryItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@ -92,5 +93,13 @@ public class ProposalDbContext : DbContext
entity.HasKey(e => e.Id);
entity.HasOne(e => e.Proposal).WithMany().HasForeignKey(e => e.ProposalId).OnDelete(DeleteBehavior.Cascade);
});
// PR5: Pricing Library entity configuration
modelBuilder.Entity<PricingLibraryItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.UnitPrice).HasPrecision(18, 2);
entity.Property(e => e.ServiceCategory).HasConversion<string>();
});
}
}

View file

@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageReference Include="AWSSDK.S3" Version="3.7.405.0" />
<PackageReference Include="AWSSDK.SimpleEmailV2" Version="3.7.500.0" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.500.0" />
<PackageReference Include="AWSSDK.SecretsManager" Version="3.7.500.0" />
</ItemGroup>

View file

@ -31,12 +31,30 @@ public class CustomerService : ICustomerService
.Take(50)
.ToListAsync(ct);
return customers.Select(c => new CustomerResponse(
c.Id,
c.Name,
DeserializeAddresses(c.Addresses),
c.CreatedAt
)).ToList();
return customers.Select(MapToResponse).ToList();
}
public async Task<PagedResponse<CustomerResponse>> ListAsync(int page, int pageSize, CancellationToken ct = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Customers.AsNoTracking().AsQueryable();
var totalCount = await query.CountAsync(ct);
var items = await query
.OrderBy(c => c.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return new PagedResponse<CustomerResponse>(
items.Select(MapToResponse).ToList(),
totalCount,
page,
pageSize
);
}
public async Task<CustomerResponse> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default)
@ -47,6 +65,7 @@ public class CustomerService : ICustomerService
Id = Guid.NewGuid(),
Name = request.Name,
Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null,
ContactEmail = request.ContactEmail,
CreatedAt = now,
UpdatedAt = now,
};
@ -54,14 +73,40 @@ public class CustomerService : ICustomerService
_db.Customers.Add(customer);
await _db.SaveChangesAsync(ct);
return new CustomerResponse(
customer.Id,
customer.Name,
request.Addresses ?? new List<string>(),
customer.CreatedAt
);
return MapToResponse(customer);
}
public async Task<CustomerResponse?> UpdateAsync(Guid id, UpdateCustomerRequest request, CancellationToken ct = default)
{
var customer = await _db.Customers.FindAsync(new object[] { id }, ct);
if (customer == null) return null;
if (request.Name != null)
customer.Name = request.Name;
if (request.Addresses != null)
customer.Addresses = JsonSerializer.Serialize(request.Addresses);
// ContactEmail is nullable — allow setting it to empty string to clear, or a value to set.
// Since UpdateCustomerRequest uses string?, we check if the property was explicitly provided.
// With records and JSON deserialization, null means "not provided" vs empty string means "clear".
if (request.ContactEmail != null)
customer.ContactEmail = string.IsNullOrEmpty(request.ContactEmail) ? null : request.ContactEmail;
customer.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return MapToResponse(customer);
}
private static CustomerResponse MapToResponse(Customer c) => new(
c.Id,
c.Name,
DeserializeAddresses(c.Addresses),
c.ContactEmail,
c.CreatedAt
);
private static List<string> DeserializeAddresses(string? json)
{
if (string.IsNullOrEmpty(json)) return new List<string>();

View file

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

View file

@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
@ -16,6 +17,9 @@ public class ProposalService : IProposalService
private readonly IProposalNumberGenerator _numberGenerator;
private readonly IAuditService _audit;
private readonly IJobPublisher _jobPublisher;
private readonly IEmailService _emailService;
private readonly IS3Service _s3;
private readonly IConfiguration _config;
private readonly ILogger<ProposalService> _logger;
public ProposalService(
@ -24,6 +28,9 @@ public class ProposalService : IProposalService
IProposalNumberGenerator numberGenerator,
IAuditService audit,
IJobPublisher jobPublisher,
IEmailService emailService,
IS3Service s3,
IConfiguration config,
ILogger<ProposalService> logger)
{
_db = db;
@ -31,6 +38,9 @@ public class ProposalService : IProposalService
_numberGenerator = numberGenerator;
_audit = audit;
_jobPublisher = jobPublisher;
_emailService = emailService;
_s3 = s3;
_config = config;
_logger = logger;
}
@ -328,11 +338,92 @@ public class ProposalService : IProposalService
_logger.LogInformation("Proposal {ProposalId} marked as sent by user {UserId}", id, _currentUser.UserId);
// PR4: Proposal delivery — email customer the PDF link.
// CRITICAL: email/presign failures must NOT roll back the Sent transition (idempotency convention).
try
{
await DeliverProposalEmailAsync(proposal, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Proposal delivery email failed for {ProposalId}, continuing with Sent transition", id);
}
await _jobPublisher.PublishAsync("library-ingest", new { proposalId = id }, ct);
return MapToResponse(proposal);
}
/// <summary>
/// Attempts to email the proposal PDF to the customer. Failures are logged but never
/// propagated — the Sent transition has already been committed and must not be rolled back.
/// </summary>
private async Task DeliverProposalEmailAsync(Proposal proposal, CancellationToken ct)
{
// Look up customer by name to get ContactEmail
var customer = await _db.Customers
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Name == proposal.CustomerName, ct);
if (customer == null)
{
_logger.LogWarning(
"No customer record found for {CustomerName} on proposal {ProposalId}, skipping delivery email",
proposal.CustomerName, proposal.Id);
return;
}
if (string.IsNullOrEmpty(customer.ContactEmail))
{
_logger.LogWarning(
"Customer {CustomerId} ({CustomerName}) has no ContactEmail, skipping delivery email for proposal {ProposalId}",
customer.Id, customer.Name, proposal.Id);
return;
}
// Get the latest generated PDF
var latestPdf = await _db.GeneratedPdfs
.AsNoTracking()
.Where(p => p.ProposalId == proposal.Id)
.OrderByDescending(p => p.Revision)
.FirstOrDefaultAsync(ct);
if (latestPdf == null)
{
_logger.LogWarning(
"No generated PDF found for proposal {ProposalId}, skipping delivery email",
proposal.Id);
return;
}
// Generate a 7-day presigned download URL
var bucket = _config["GENERATED_BUCKET"] ?? string.Empty;
var pdfUrl = await _s3.GeneratePresignedDownloadUrlAsync(bucket, latestPdf.S3Key, expirationMinutes: 7 * 24 * 60);
await _emailService.SendProposalDeliveryAsync(
customer.ContactEmail,
proposal.ProposalNumber,
customer.Name,
pdfUrl,
ct);
// Record delivery attempt in audit log
try
{
var deliveryDetails = JsonSerializer.Serialize(new
{
recipient = customer.ContactEmail,
proposalNumber = proposal.ProposalNumber,
pdfRevision = latestPdf.Revision,
});
await _audit.LogAsync(AuditAction.DeliverEmail, proposal.Id, deliveryDetails, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write delivery audit log for proposal {ProposalId}", proposal.Id);
}
}
public async Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals

View file

@ -0,0 +1,86 @@
using Amazon.SimpleEmailV2;
using Amazon.SimpleEmailV2.Model;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using ProposalSystem.Application.Interfaces;
namespace ProposalSystem.Infrastructure.Services;
public class SesEmailService : IEmailService
{
private readonly IAmazonSimpleEmailServiceV2 _ses;
private readonly string _fromAddress;
private readonly ILogger<SesEmailService> _logger;
public SesEmailService(
IAmazonSimpleEmailServiceV2 ses,
IConfiguration config,
ILogger<SesEmailService> logger)
{
_ses = ses;
_fromAddress = config["SES_FROM_ADDRESS"]
?? throw new InvalidOperationException(
"SES_FROM_ADDRESS environment variable is required for email delivery.");
_logger = logger;
}
public async Task SendProposalDeliveryAsync(
string toEmail,
string proposalNumber,
string customerName,
string pdfUrl,
CancellationToken ct = default)
{
var subject = $"Your proposal {proposalNumber} from Sea Haven Industries";
var htmlBody = $@"
<html>
<body style=""font-family: Arial, sans-serif; color: #333;"">
<p>Dear {System.Net.WebUtility.HtmlEncode(customerName)},</p>
<p>Your proposal <strong>{System.Net.WebUtility.HtmlEncode(proposalNumber)}</strong> is ready for review.</p>
<p>
<a href=""{System.Net.WebUtility.HtmlEncode(pdfUrl)}"" style=""display: inline-block; padding: 10px 20px; background-color: #0066cc; color: #ffffff; text-decoration: none; border-radius: 4px;"">View Proposal PDF</a>
</p>
<p>This link will expire in 7 days. If you have any questions, please contact us directly.</p>
<p>Thank you,<br/>Sea Haven Industries</p>
</body>
</html>";
var textBody = $@"Dear {customerName},
Your proposal {proposalNumber} is ready for review.
View the proposal PDF at: {pdfUrl}
This link will expire in 7 days. If you have any questions, please contact us directly.
Thank you,
Sea Haven Industries";
var request = new SendEmailRequest
{
FromEmailAddress = _fromAddress,
Destination = new Destination
{
ToAddresses = new List<string> { toEmail },
},
Content = new EmailContent
{
Simple = new Message
{
Subject = new Content { Data = subject },
Body = new Body
{
Html = new Content { Data = htmlBody },
Text = new Content { Data = textBody },
},
},
},
};
await _ses.SendEmailAsync(request, ct);
_logger.LogInformation(
"Proposal delivery email sent for {ProposalNumber}",
proposalNumber);
}
}

View file

@ -172,6 +172,7 @@ public class AuthorizationAttributeTests
[InlineData(typeof(GeneratedPdfsController))]
[InlineData(typeof(FilesController))]
[InlineData(typeof(VendorProposalsController))]
[InlineData(typeof(PricingLibraryController))]
public void AllControllers_HaveAuthorizeAttribute(Type controllerType)
{
var attr = controllerType.GetCustomAttribute<AuthorizeAttribute>();
@ -180,4 +181,47 @@ public class AuthorizationAttributeTests
}
#endregion
#region PR5: PricingLibraryController Authorization
[Fact(DisplayName = "PR5: PricingLibraryController requires authentication")]
public void PricingLibraryController_HasAuthorizeAttribute()
{
typeof(PricingLibraryController)
.GetCustomAttribute<AuthorizeAttribute>()
.Should().NotBeNull("PricingLibraryController should require authentication");
}
[Theory(DisplayName = "PR5: PricingLibrary admin actions require admins/sysadmins role")]
[InlineData("List")]
[InlineData("GetById")]
[InlineData("Create")]
[InlineData("Update")]
[InlineData("Delete")]
public void PricingLibraryController_AdminActions_RequireAdminRole(string methodName)
{
var method = typeof(PricingLibraryController).GetMethod(methodName);
method.Should().NotBeNull($"PricingLibraryController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("admins", $"{methodName} should allow admins (including internal Lambda callers)")
.And.Contain("sysadmins", $"{methodName} should allow sysadmins");
}
[Fact(DisplayName = "PR5: PricingLibrary GetById is reachable by internal Lambda callers (admins role)")]
public void PricingLibraryController_GetById_AllowsAdminsRole()
{
var method = typeof(PricingLibraryController).GetMethod("GetById");
method.Should().NotBeNull();
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
attr!.Roles.Should().Contain("admins",
"GetById must be reachable by the internal API key middleware which assigns the 'admins' role");
}
#endregion
}

View file

@ -0,0 +1,326 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Services;
using ProposalSystem.Tests.Helpers;
using Xunit;
namespace ProposalSystem.Tests.Services;
/// <summary>
/// PR5: Tests for PricingLibraryService. Verifies CRUD behavior and that
/// create/update publish "library-ingest" SQS jobs for KB sync.
/// </summary>
public class PricingLibraryServiceTests : IDisposable
{
private readonly Infrastructure.Data.ProposalDbContext _db;
private readonly IJobPublisher _jobPublisher;
private readonly PricingLibraryService _sut;
public PricingLibraryServiceTests()
{
_db = DbContextFactory.Create();
_jobPublisher = Substitute.For<IJobPublisher>();
_sut = new PricingLibraryService(
_db,
_jobPublisher,
Substitute.For<ILogger<PricingLibraryService>>());
}
public void Dispose()
{
_db.Dispose();
}
#region Create
[Fact(DisplayName = "PR5: Create returns item with generated Id and timestamps")]
public async Task CreateAsync_ReturnsItemWithIdAndTimestamps()
{
var request = new CreatePricingLibraryItemRequest(
Title: "HVAC Filter Replacement",
Description: "Standard filter replacement service",
ServiceCategory: ServiceCategory.HVAC,
Unit: "ea",
UnitPrice: 45.00m,
Keywords: "filter HVAC maintenance",
Source: "Manual"
);
var result = await _sut.CreateAsync(request);
result.Id.Should().NotBe(Guid.Empty);
result.Title.Should().Be("HVAC Filter Replacement");
result.Description.Should().Be("Standard filter replacement service");
result.ServiceCategory.Should().Be(ServiceCategory.HVAC);
result.Unit.Should().Be("ea");
result.UnitPrice.Should().Be(45.00m);
result.Keywords.Should().Be("filter HVAC maintenance");
result.Source.Should().Be("Manual");
result.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact(DisplayName = "PR5: Create defaults Source to 'Manual' when null")]
public async Task CreateAsync_DefaultsSourceToManual()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Basic Item",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = await _sut.CreateAsync(request);
result.Source.Should().Be("Manual");
}
[Fact(DisplayName = "PR5: Create publishes library-ingest job with pricingLibraryItemId")]
public async Task CreateAsync_PublishesLibraryIngestJob()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Test Item",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = await _sut.CreateAsync(request);
await _jobPublisher.Received(1).PublishAsync(
"library-ingest",
Arg.Is<object>(o => o.ToString()!.Contains(result.Id.ToString())),
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR5: Create succeeds even when job publish fails (no rollback)")]
public async Task CreateAsync_JobPublishFails_DoesNotRollBack()
{
_jobPublisher.PublishAsync(Arg.Any<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("SQS is down"));
var request = new CreatePricingLibraryItemRequest(
Title: "Resilient Item",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = await _sut.CreateAsync(request);
result.Title.Should().Be("Resilient Item");
// Verify item was persisted despite job publish failure
var dbItem = await _db.PricingLibraryItems.FindAsync(result.Id);
dbItem.Should().NotBeNull();
}
#endregion
#region Update
[Fact(DisplayName = "PR5: Update modifies provided fields and publishes ingest job")]
public async Task UpdateAsync_ModifiesFieldsAndPublishesJob()
{
// Seed an item
var originalUpdatedAt = DateTime.UtcNow.AddDays(-1);
var item = new PricingLibraryItem
{
Id = Guid.NewGuid(),
Title = "Original Title",
Description = "Original",
ServiceCategory = ServiceCategory.General,
Unit = "ea",
UnitPrice = 10.00m,
Keywords = "original",
Source = "Manual",
CreatedAt = DateTime.UtcNow.AddDays(-1),
UpdatedAt = originalUpdatedAt,
};
_db.PricingLibraryItems.Add(item);
await _db.SaveChangesAsync();
var request = new UpdatePricingLibraryItemRequest(
Title: "Updated Title",
Description: null,
ServiceCategory: ServiceCategory.Electrical,
Unit: null,
UnitPrice: 99.99m,
Keywords: null
);
var result = await _sut.UpdateAsync(item.Id, request);
result.Should().NotBeNull();
result!.Title.Should().Be("Updated Title");
result.ServiceCategory.Should().Be(ServiceCategory.Electrical);
result.UnitPrice.Should().Be(99.99m);
result.UpdatedAt.Should().BeAfter(originalUpdatedAt);
await _jobPublisher.Received(1).PublishAsync(
"library-ingest",
Arg.Is<object>(o => o.ToString()!.Contains(item.Id.ToString())),
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR5: Update returns null for non-existent item")]
public async Task UpdateAsync_NonExistentItem_ReturnsNull()
{
var request = new UpdatePricingLibraryItemRequest(
Title: "Does Not Exist",
Description: null,
ServiceCategory: null,
Unit: null,
UnitPrice: null,
Keywords: null
);
var result = await _sut.UpdateAsync(Guid.NewGuid(), request);
result.Should().BeNull();
}
[Fact(DisplayName = "PR5: Update succeeds even when job publish fails (no rollback)")]
public async Task UpdateAsync_JobPublishFails_DoesNotRollBack()
{
var item = new PricingLibraryItem
{
Id = Guid.NewGuid(),
Title = "Will Be Updated",
ServiceCategory = ServiceCategory.General,
Source = "Manual",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_db.PricingLibraryItems.Add(item);
await _db.SaveChangesAsync();
_jobPublisher.PublishAsync(Arg.Any<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("SQS is down"));
var request = new UpdatePricingLibraryItemRequest(
Title: "Updated Despite SQS Failure",
Description: null,
ServiceCategory: null,
Unit: null,
UnitPrice: null,
Keywords: null
);
var result = await _sut.UpdateAsync(item.Id, request);
result.Should().NotBeNull();
result!.Title.Should().Be("Updated Despite SQS Failure");
}
#endregion
#region GetById
[Fact(DisplayName = "PR5: GetById returns item when it exists")]
public async Task GetByIdAsync_ExistingItem_ReturnsItem()
{
var item = new PricingLibraryItem
{
Id = Guid.NewGuid(),
Title = "Findable Item",
ServiceCategory = ServiceCategory.Plumbing,
Source = "Manual",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_db.PricingLibraryItems.Add(item);
await _db.SaveChangesAsync();
var result = await _sut.GetByIdAsync(item.Id);
result.Should().NotBeNull();
result!.Id.Should().Be(item.Id);
result.Title.Should().Be("Findable Item");
}
[Fact(DisplayName = "PR5: GetById returns null for non-existent item")]
public async Task GetByIdAsync_NonExistentItem_ReturnsNull()
{
var result = await _sut.GetByIdAsync(Guid.NewGuid());
result.Should().BeNull();
}
#endregion
#region Delete
[Fact(DisplayName = "PR5: Delete removes item and returns true")]
public async Task DeleteAsync_ExistingItem_RemovesAndReturnsTrue()
{
var item = new PricingLibraryItem
{
Id = Guid.NewGuid(),
Title = "To Be Deleted",
ServiceCategory = ServiceCategory.General,
Source = "Manual",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_db.PricingLibraryItems.Add(item);
await _db.SaveChangesAsync();
var result = await _sut.DeleteAsync(item.Id);
result.Should().BeTrue();
var dbItem = await _db.PricingLibraryItems.FindAsync(item.Id);
dbItem.Should().BeNull();
}
[Fact(DisplayName = "PR5: Delete returns false for non-existent item")]
public async Task DeleteAsync_NonExistentItem_ReturnsFalse()
{
var result = await _sut.DeleteAsync(Guid.NewGuid());
result.Should().BeFalse();
}
#endregion
#region List
[Fact(DisplayName = "PR5: List returns paginated results ordered by title")]
public async Task ListAsync_ReturnsPaginatedResults()
{
// Seed 3 items
var items = new[]
{
new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Charlie", ServiceCategory = ServiceCategory.General, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow },
new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Alpha", ServiceCategory = ServiceCategory.HVAC, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow },
new PricingLibraryItem { Id = Guid.NewGuid(), Title = "Bravo", ServiceCategory = ServiceCategory.Plumbing, Source = "Manual", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow },
};
_db.PricingLibraryItems.AddRange(items);
await _db.SaveChangesAsync();
var result = await _sut.ListAsync(1, 2);
result.TotalCount.Should().Be(3);
result.Page.Should().Be(1);
result.PageSize.Should().Be(2);
result.Items.Should().HaveCount(2);
result.Items[0].Title.Should().Be("Alpha");
result.Items[1].Title.Should().Be("Bravo");
}
#endregion
}

View file

@ -0,0 +1,251 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Services;
using ProposalSystem.Tests.Helpers;
using Xunit;
namespace ProposalSystem.Tests.Services;
/// <summary>
/// PR4: Proposal delivery tests. Verifies that:
/// 1. MarkSent succeeds (transition completes) even when the email service throws.
/// 2. Email is sent when customer and PDF exist.
/// 3. Email is skipped gracefully when customer or PDF is missing.
/// </summary>
public class ProposalDeliveryTests : IDisposable
{
private readonly Infrastructure.Data.ProposalDbContext _db;
private readonly ICurrentUserService _currentUser;
private readonly IAuditService _audit;
private readonly IJobPublisher _jobPublisher;
private readonly IProposalNumberGenerator _numberGenerator;
private readonly IEmailService _emailService;
private readonly IS3Service _s3Service;
private readonly ProposalService _sut;
private readonly Guid _userId;
public ProposalDeliveryTests()
{
_db = DbContextFactory.Create();
_currentUser = Substitute.For<ICurrentUserService>();
_audit = Substitute.For<IAuditService>();
_jobPublisher = Substitute.For<IJobPublisher>();
_numberGenerator = Substitute.For<IProposalNumberGenerator>();
_emailService = Substitute.For<IEmailService>();
_s3Service = Substitute.For<IS3Service>();
_userId = Guid.NewGuid();
_currentUser.UserId.Returns(_userId);
_currentUser.Role.Returns(UserRole.Admin);
_db.Users.Add(new User
{
Id = _userId,
CognitoSub = $"sub-{_userId}",
Email = "admin@test.com",
DisplayName = "Test Admin",
Role = UserRole.Admin,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.SaveChanges();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "GENERATED_BUCKET", "test-bucket" },
})
.Build();
_sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher,
_emailService, _s3Service, config,
Substitute.For<ILogger<ProposalService>>());
}
public void Dispose()
{
_db.Dispose();
}
[Fact(DisplayName = "PR4: MarkSent completes transition even when email service throws")]
public async Task MarkSentAsync_EmailServiceThrows_TransitionStillCompletes()
{
// Arrange — customer with email and a generated PDF
var proposal = CreateProposal(ProposalStatus.Approved, "Failing Customer");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "Failing Customer",
ContactEmail = "test@example.com",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.GeneratedPdfs.Add(new GeneratedPdf
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Revision = 1,
S3Key = "pdfs/test.pdf",
GeneratedAt = DateTime.UtcNow,
GeneratedById = _userId,
});
await _db.SaveChangesAsync();
_s3Service.GeneratePresignedDownloadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns("https://s3.example.com/test.pdf");
// Make the email service throw
_emailService.SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("SES is down"));
// Act — should NOT throw
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert — transition completed despite email failure
result.Status.Should().Be(ProposalStatus.Sent);
result.SentAt.Should().NotBeNull();
}
[Fact(DisplayName = "PR4: MarkSent sends email when customer and PDF exist")]
public async Task MarkSentAsync_CustomerAndPdfExist_SendsEmail()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved, "Acme Corp");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "Acme Corp",
ContactEmail = "billing@acme.com",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.GeneratedPdfs.Add(new GeneratedPdf
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Revision = 1,
S3Key = "pdfs/acme.pdf",
GeneratedAt = DateTime.UtcNow,
GeneratedById = _userId,
});
await _db.SaveChangesAsync();
_s3Service.GeneratePresignedDownloadUrlAsync("test-bucket", "pdfs/acme.pdf", 7 * 24 * 60)
.Returns("https://s3.example.com/acme.pdf");
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.Received(1).SendProposalDeliveryAsync(
"billing@acme.com",
proposal.ProposalNumber,
"Acme Corp",
"https://s3.example.com/acme.pdf",
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR4: MarkSent skips email when customer has no ContactEmail")]
public async Task MarkSentAsync_NoContactEmail_SkipsEmail()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved, "No Email Corp");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "No Email Corp",
ContactEmail = null,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.DidNotReceive().SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR4: MarkSent skips email when no customer record matches")]
public async Task MarkSentAsync_NoCustomerRecord_SkipsEmail()
{
// Arrange — no customer in DB
var proposal = CreateProposal(ProposalStatus.Approved, "Unknown Customer");
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.DidNotReceive().SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "PR4: MarkSent skips email when no PDF has been generated")]
public async Task MarkSentAsync_NoPdfGenerated_SkipsEmail()
{
// Arrange — customer exists but no PDF
var proposal = CreateProposal(ProposalStatus.Approved, "PDF-less Corp");
_db.Proposals.Add(proposal);
_db.Customers.Add(new Customer
{
Id = Guid.NewGuid(),
Name = "PDF-less Corp",
ContactEmail = "contact@pdfless.com",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
await _emailService.DidNotReceive().SendProposalDeliveryAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
private Proposal CreateProposal(ProposalStatus status, string customerName)
{
return new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = $"SHI-2026-{Guid.NewGuid():N}"[..16],
WorkOrderNumber = "WO-001",
CustomerName = customerName,
CustomerAddress = "123 Test St",
ScopeOfWork = "Test scope of work",
ServiceCategory = ServiceCategory.HVAC,
Priority = Priority.Standard,
Status = status,
Notes = "",
SubmittedById = _userId,
SubmittedAt = DateTime.UtcNow.AddDays(-1),
CurrentRevision = 1,
CreatedAt = DateTime.UtcNow.AddDays(-1),
UpdatedAt = DateTime.UtcNow.AddDays(-1),
};
}
}

View file

@ -1,4 +1,5 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSubstitute;
using ProposalSystem.Application.Interfaces;
@ -21,6 +22,8 @@ public class ProposalStateMachineTests : IDisposable
private readonly IAuditService _audit;
private readonly IJobPublisher _jobPublisher;
private readonly IProposalNumberGenerator _numberGenerator;
private readonly IEmailService _emailService;
private readonly IS3Service _s3Service;
private readonly ProposalService _sut;
public ProposalStateMachineTests()
@ -30,6 +33,8 @@ public class ProposalStateMachineTests : IDisposable
_audit = Substitute.For<IAuditService>();
_jobPublisher = Substitute.For<IJobPublisher>();
_numberGenerator = Substitute.For<IProposalNumberGenerator>();
_emailService = Substitute.For<IEmailService>();
_s3Service = Substitute.For<IS3Service>();
var userId = Guid.NewGuid();
_currentUser.UserId.Returns(userId);
@ -48,7 +53,15 @@ public class ProposalStateMachineTests : IDisposable
});
_db.SaveChanges();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "GENERATED_BUCKET", "test-bucket" },
})
.Build();
_sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher,
_emailService, _s3Service, config,
Substitute.For<ILogger<ProposalService>>());
}

View file

@ -0,0 +1,239 @@
using FluentAssertions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using ProposalSystem.Domain.Entities;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// PR5: Tests for CreatePricingLibraryItemValidator.
/// </summary>
public class CreatePricingLibraryItemValidatorTests
{
private readonly CreatePricingLibraryItemValidator _sut = new();
[Fact(DisplayName = "PR5: Valid pricing library item passes validation")]
public void ValidRequest_Passes()
{
var request = new CreatePricingLibraryItemRequest(
Title: "HVAC Filter Replacement",
Description: "Standard 20x25x1 MERV-13 filter replacement",
ServiceCategory: ServiceCategory.HVAC,
Unit: "ea",
UnitPrice: 45.00m,
Keywords: "filter HVAC maintenance preventive",
Source: "Manual"
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "PR5: Empty title fails validation")]
public void EmptyTitle_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: "",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Title");
}
[Fact(DisplayName = "PR5: Title exceeding 200 chars fails")]
public void Title_TooLong_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: new string('X', 201),
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Title");
}
[Fact(DisplayName = "PR5: Description exceeding 5000 chars fails")]
public void Description_TooLong_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Valid Title",
Description: new string('X', 5001),
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Description");
}
[Fact(DisplayName = "PR5: Negative UnitPrice fails")]
public void NegativeUnitPrice_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Valid Title",
Description: null,
ServiceCategory: ServiceCategory.Plumbing,
Unit: "hr",
UnitPrice: -10.00m,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice");
}
[Fact(DisplayName = "PR5: Zero UnitPrice is valid")]
public void ZeroUnitPrice_Passes()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Complimentary Inspection",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: "ea",
UnitPrice: 0m,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "PR5: Null UnitPrice is valid")]
public void NullUnitPrice_Passes()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Variable Price Item",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "PR5: Keywords exceeding 2000 chars fails")]
public void Keywords_TooLong_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Valid Title",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: new string('X', 2001),
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Keywords");
}
[Fact(DisplayName = "PR5: Invalid ServiceCategory enum value fails")]
public void InvalidServiceCategory_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Valid Title",
Description: null,
ServiceCategory: (ServiceCategory)999,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory");
}
[Fact(DisplayName = "PR5: Unit exceeding 50 chars fails")]
public void Unit_TooLong_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Valid Title",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: new string('X', 51),
UnitPrice: null,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Unit");
}
[Fact(DisplayName = "PR5: Source exceeding 100 chars fails")]
public void Source_TooLong_Fails()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Valid Title",
Description: null,
ServiceCategory: ServiceCategory.General,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: new string('X', 101)
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Source");
}
[Fact(DisplayName = "PR5: Minimal valid request (title + category only) passes")]
public void MinimalValidRequest_Passes()
{
var request = new CreatePricingLibraryItemRequest(
Title: "Basic Item",
Description: null,
ServiceCategory: ServiceCategory.Other,
Unit: null,
UnitPrice: null,
Keywords: null,
Source: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
}

View file

@ -0,0 +1,99 @@
using FluentAssertions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// PR4: Tests for customer email format validation on both Create and Update DTOs.
/// Ensures the ContactEmail field, when provided, must be a valid email address.
/// </summary>
public class CustomerEmailValidatorTests
{
private readonly CreateCustomerValidator _createValidator = new();
private readonly UpdateCustomerValidator _updateValidator = new();
#region CreateCustomerValidator — ContactEmail
[Fact(DisplayName = "Create: null ContactEmail passes validation")]
public void Create_NullEmail_Passes()
{
var request = new CreateCustomerRequest("Acme Corp", null, null);
var result = _createValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "Create: valid ContactEmail passes validation")]
public void Create_ValidEmail_Passes()
{
var request = new CreateCustomerRequest("Acme Corp", null, "john@example.com");
var result = _createValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Theory(DisplayName = "Create: invalid ContactEmail fails validation")]
[InlineData("not-an-email")]
[InlineData("missing@")]
[InlineData("@no-local.com")]
public void Create_InvalidEmail_Fails(string email)
{
var request = new CreateCustomerRequest("Acme Corp", null, email);
var result = _createValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ContactEmail");
}
[Fact(DisplayName = "Create: ContactEmail exceeding 320 chars fails")]
public void Create_EmailTooLong_Fails()
{
var longEmail = new string('a', 310) + "@example.com"; // 322 chars
var request = new CreateCustomerRequest("Acme Corp", null, longEmail);
var result = _createValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ContactEmail");
}
#endregion
#region UpdateCustomerValidator — ContactEmail
[Fact(DisplayName = "Update: null ContactEmail passes validation")]
public void Update_NullEmail_Passes()
{
var request = new UpdateCustomerRequest(null, null, null);
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "Update: valid ContactEmail passes validation")]
public void Update_ValidEmail_Passes()
{
var request = new UpdateCustomerRequest(null, null, "john@example.com");
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeTrue();
}
[Theory(DisplayName = "Update: invalid ContactEmail fails validation")]
[InlineData("not-an-email")]
[InlineData("missing@")]
[InlineData("@no-local.com")]
public void Update_InvalidEmail_Fails(string email)
{
var request = new UpdateCustomerRequest(null, null, email);
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ContactEmail");
}
[Fact(DisplayName = "Update: name exceeding 200 chars fails")]
public void Update_NameTooLong_Fails()
{
var request = new UpdateCustomerRequest(new string('X', 201), null, null);
var result = _updateValidator.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Name");
}
#endregion
}

View file

@ -0,0 +1,121 @@
using FluentAssertions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using ProposalSystem.Domain.Entities;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// PR5: Tests for UpdatePricingLibraryItemValidator.
/// </summary>
public class UpdatePricingLibraryItemValidatorTests
{
private readonly UpdatePricingLibraryItemValidator _sut = new();
[Fact(DisplayName = "PR5: Valid update request passes")]
public void ValidRequest_Passes()
{
var request = new UpdatePricingLibraryItemRequest(
Title: "Updated Title",
Description: "Updated description",
ServiceCategory: ServiceCategory.Electrical,
Unit: "hr",
UnitPrice: 125.50m,
Keywords: "electrical wiring"
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "PR5: All-null update request passes (no changes)")]
public void AllNullRequest_Passes()
{
var request = new UpdatePricingLibraryItemRequest(
Title: null,
Description: null,
ServiceCategory: null,
Unit: null,
UnitPrice: null,
Keywords: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "PR5: Empty title when provided fails")]
public void EmptyTitle_Fails()
{
var request = new UpdatePricingLibraryItemRequest(
Title: "",
Description: null,
ServiceCategory: null,
Unit: null,
UnitPrice: null,
Keywords: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Title");
}
[Fact(DisplayName = "PR5: Title exceeding 200 chars fails")]
public void Title_TooLong_Fails()
{
var request = new UpdatePricingLibraryItemRequest(
Title: new string('X', 201),
Description: null,
ServiceCategory: null,
Unit: null,
UnitPrice: null,
Keywords: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Title");
}
[Fact(DisplayName = "PR5: Negative UnitPrice fails")]
public void NegativeUnitPrice_Fails()
{
var request = new UpdatePricingLibraryItemRequest(
Title: null,
Description: null,
ServiceCategory: null,
Unit: null,
UnitPrice: -5.00m,
Keywords: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice");
}
[Fact(DisplayName = "PR5: Invalid ServiceCategory enum fails")]
public void InvalidServiceCategory_Fails()
{
var request = new UpdatePricingLibraryItemRequest(
Title: null,
Description: null,
ServiceCategory: (ServiceCategory)999,
Unit: null,
UnitPrice: null,
Keywords: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory");
}
}

View file

@ -5,6 +5,7 @@ import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as ses from 'aws-cdk-lib/aws-ses';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns';
@ -204,6 +205,7 @@ export class ComputeStack extends cdk.Stack {
Auth__CognitoDomain: `${config.cognitoDomainPrefix}.auth.${this.region}.amazoncognito.com`,
COGNITO_WEB_CLIENT_ID: props.webClientId,
COGNITO_MOBILE_CLIENT_ID: props.mobileClientId,
SES_FROM_ADDRESS: 'proposals@seahavenind.com',
},
tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.TWO_MONTHS,
@ -221,6 +223,32 @@ export class ComputeStack extends cdk.Stack {
resources: [props.userPool.userPoolArn],
}));
// PR4: SES email identity for proposal delivery.
// NOTE: New SES identities start in the **sandbox**. While in sandbox mode the
// account can only send email to *verified* recipient addresses. To send to
// arbitrary recipients, submit a production-access request via the AWS SES console
// (Support Center → "SES Sending Limits Increase"). The email identity itself
// must also be verified — AWS sends a click-link email to the address below after
// deployment; someone with access to the mailbox must click it.
const sesFromAddress = 'proposals@seahavenind.com';
const sesSenderIdentity = new ses.EmailIdentity(this, 'SesSenderIdentity', {
identity: ses.Identity.email(sesFromAddress),
});
// Grant the API Lambda least-privilege SES send permission, scoped to the
// sender identity ARN only (not ses:* / resource:*).
const senderIdentityArn = `arn:aws:ses:${this.region}:${this.account}:identity/${sesFromAddress}`;
apiFunction.addToRolePolicy(new iam.PolicyStatement({
actions: ['ses:SendEmail', 'ses:SendRawEmail'],
resources: [senderIdentityArn],
// Defense-in-depth (cross-review): only send AS this address, even if the
// identity scope is later widened.
conditions: {
StringEquals: { 'ses:FromAddress': sesFromAddress },
},
}));
void sesSenderIdentity;
// Fix: LAM-C1/INF-H1 — require IAM auth on Function URL (was authType NONE).
// NOTE: Lambda HTTP clients (suggestions, pdf-extract, pdf-generate, library-ingest)
// must use SigV4 signing when calling this URL. The API key header alone is no longer

View file

@ -1,8 +1,12 @@
"""Proposal System - Library Ingest Lambda.
Processes approved/sent proposals into the Bedrock Knowledge Base library.
Formats proposal data as structured markdown and uploads to the library bucket,
then triggers a KB sync.
Processes approved/sent proposals and curated pricing library items into the
Bedrock Knowledge Base library. Formats data as structured markdown and uploads
to the library bucket, then triggers a KB sync.
Supports two SQS message shapes (PR5):
- Proposal: {"jobType":"library-ingest","payload":{"proposalId":"<guid>"}}
- Pricing item: {"jobType":"library-ingest","payload":{"pricingLibraryItemId":"<guid>"}}
"""
import json
@ -101,8 +105,24 @@ def handler(event, context):
try:
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
process_ingestion(proposal_id)
# PR5: branch on payload shape — pricing item vs proposal.
pricing_item_id = payload.get("pricingLibraryItemId")
proposal_id = payload.get("proposalId")
if pricing_item_id and proposal_id:
logger.warning(
"Record %s has both pricingLibraryItemId and proposalId; "
"treating as a pricing item",
record.get("messageId"),
)
if pricing_item_id:
process_pricing_item_ingestion(pricing_item_id)
elif proposal_id:
process_ingestion(proposal_id)
else:
raise ValueError(
"library-ingest payload has neither pricingLibraryItemId nor proposalId"
)
except Exception as e:
# Fix: LAM-M5 — include stack trace in error logging
logger.exception(
@ -128,6 +148,104 @@ def process_ingestion(proposal_id: str):
trigger_kb_sync()
def process_pricing_item_ingestion(item_id: str):
"""PR5: Ingest a curated pricing library item into the Bedrock Knowledge Base."""
item = fetch_pricing_item(item_id)
if not item:
logger.warning("Pricing library item %s not found, skipping", item_id)
return
document = format_pricing_item_document(item)
s3_key = upload_pricing_item_to_library(item, document)
if s3_key:
trigger_kb_sync()
def fetch_pricing_item(item_id: str) -> dict | None:
"""PR5: Fetch a pricing library item from the .NET API."""
try:
resp = _retry_request(
"GET",
f"{API_BASE_URL}/api/pricing-library/{item_id}",
headers=_api_headers(),
)
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.exception("Error fetching pricing library item: %s", e)
return None
def format_pricing_item_document(item: dict) -> str:
"""PR5: Format a pricing library item as a markdown document for Bedrock KB embedding."""
unit_price = item.get("unitPrice")
price_str = f"${unit_price:,.2f}" if unit_price is not None else "-"
keywords = item.get("keywords") or []
keywords_str = ", ".join(keywords) if isinstance(keywords, list) else str(keywords)
lines = [
f"# Pricing Item: {item.get('title', '')}",
"",
f"**Service Category:** {item.get('serviceCategory', '')}",
f"**Unit:** {item.get('unit', '')}",
f"**Unit Price:** {price_str}",
f"**Source:** {item.get('source', '')}",
"",
"## Description",
"",
item.get("description", ""),
"",
"## Keywords",
"",
keywords_str,
]
return "\n".join(lines)
def upload_pricing_item_to_library(item: dict, document: str) -> str | None:
"""PR5: Upload a formatted pricing item to the library S3 bucket.
Uses the ``pricing-library/{category}/{item_id}.md`` key prefix so pricing
items never collide with proposal documents stored under ``proposals/``.
"""
if not LIBRARY_BUCKET:
logger.warning("No library bucket configured")
return None
item_id = item.get("id", "unknown")
title = item.get("title", "unknown")
category = item.get("serviceCategory", "general")
s3_key = f"pricing-library/{category.lower()}/{item_id}.md"
try:
s3_key = _validate_s3_key(s3_key)
except ValueError as e:
logger.error("Invalid S3 key for pricing item %s: %s", item_id, e)
return None
try:
s3.put_object(
Bucket=LIBRARY_BUCKET,
Key=s3_key,
Body=document.encode("utf-8"),
ContentType="text/markdown",
Metadata={
"service-category": category,
"pricing-item-title": title,
"pricing-item-id": str(item_id),
},
)
logger.info("Uploaded pricing item %s to library bucket at %s", item_id, s3_key)
return s3_key
except Exception as e:
logger.exception("Error uploading pricing item to library: %s", e)
return None
def fetch_proposal(proposal_id: str) -> dict | None:
try:
resp = _retry_request(

View file

@ -0,0 +1,235 @@
"""Tests for library-ingest Lambda handler.
PR5: Covers both the existing proposal ingestion path and the new pricing
library item ingestion path.
"""
import importlib
import sys
import os
from unittest.mock import patch
from helpers import make_sqs_event
# Import library-ingest app under a unique module name (the directory name
# contains a hyphen so normal import won't work).
_library_ingest_dir = os.path.join(os.path.dirname(__file__), "..", "library-ingest")
_spec = importlib.util.spec_from_file_location(
"library_ingest_app", os.path.join(_library_ingest_dir, "app.py")
)
library_ingest_app = importlib.util.module_from_spec(_spec)
sys.modules["library_ingest_app"] = library_ingest_app
_spec.loader.exec_module(library_ingest_app)
# ---------------------------------------------------------------------------
# Sample data
# ---------------------------------------------------------------------------
SAMPLE_PRICING_ITEM = {
"id": "item-abc-123",
"title": "Standard HVAC Filter Replacement",
"serviceCategory": "HVAC",
"unit": "each",
"unitPrice": 85.50,
"description": "Replace standard 20x25 HVAC filter with MERV-13 rated filter.",
"keywords": ["hvac", "filter", "replacement", "merv-13"],
"source": "PricingLibrary",
}
# ---------------------------------------------------------------------------
# Handler routing tests
# ---------------------------------------------------------------------------
class TestHandlerRouting:
"""Verify the handler correctly routes pricing-item vs proposal messages."""
@patch.object(library_ingest_app, "process_pricing_item_ingestion")
def test_handler_routes_pricing_item_message(self, mock_process):
"""PR5: pricingLibraryItemId in payload routes to pricing item path."""
event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-abc-123"}})
result = library_ingest_app.handler(event, None)
mock_process.assert_called_once_with("item-abc-123")
assert result["batchItemFailures"] == []
@patch.object(library_ingest_app, "process_ingestion")
def test_handler_routes_proposal_message(self, mock_process):
"""PR5: proposalId in payload still routes to existing proposal path."""
event = make_sqs_event({"payload": {"proposalId": "prop-xyz-789"}})
result = library_ingest_app.handler(event, None)
mock_process.assert_called_once_with("prop-xyz-789")
assert result["batchItemFailures"] == []
@patch.object(library_ingest_app, "process_pricing_item_ingestion")
def test_handler_pricing_item_failure_goes_to_batch_failures(self, mock_process):
"""PR5: Pricing item processing failure adds messageId to batchItemFailures."""
mock_process.side_effect = RuntimeError("API unreachable")
event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-fail"}})
result = library_ingest_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-0"
@patch.object(library_ingest_app, "process_pricing_item_ingestion")
@patch.object(library_ingest_app, "process_ingestion")
def test_handler_mixed_batch(self, mock_proposal, mock_pricing):
"""PR5: A batch with both message types routes each correctly."""
event = make_sqs_event(
{"payload": {"proposalId": "prop-1"}},
{"payload": {"pricingLibraryItemId": "item-2"}},
)
result = library_ingest_app.handler(event, None)
mock_proposal.assert_called_once_with("prop-1")
mock_pricing.assert_called_once_with("item-2")
assert result["batchItemFailures"] == []
# ---------------------------------------------------------------------------
# Pricing item ingestion flow
# ---------------------------------------------------------------------------
class TestProcessPricingItemIngestion:
"""Test the full pricing item ingestion pipeline."""
@patch.object(library_ingest_app, "trigger_kb_sync")
@patch.object(library_ingest_app, "upload_pricing_item_to_library")
@patch.object(library_ingest_app, "fetch_pricing_item")
def test_full_flow(self, mock_fetch, mock_upload, mock_sync):
"""PR5: Happy path fetches item, formats, uploads, and triggers sync."""
mock_fetch.return_value = SAMPLE_PRICING_ITEM
mock_upload.return_value = "pricing-library/hvac/item-abc-123.md"
library_ingest_app.process_pricing_item_ingestion("item-abc-123")
mock_fetch.assert_called_once_with("item-abc-123")
mock_upload.assert_called_once()
mock_sync.assert_called_once()
@patch.object(library_ingest_app, "trigger_kb_sync")
@patch.object(library_ingest_app, "upload_pricing_item_to_library")
@patch.object(library_ingest_app, "fetch_pricing_item")
def test_skips_when_item_not_found(self, mock_fetch, mock_upload, mock_sync):
"""PR5: Missing item logs warning and returns without failing the batch."""
mock_fetch.return_value = None
library_ingest_app.process_pricing_item_ingestion("nonexistent")
mock_upload.assert_not_called()
mock_sync.assert_not_called()
@patch.object(library_ingest_app, "trigger_kb_sync")
@patch.object(library_ingest_app, "upload_pricing_item_to_library")
@patch.object(library_ingest_app, "fetch_pricing_item")
def test_skips_sync_when_upload_fails(self, mock_fetch, mock_upload, mock_sync):
"""PR5: No KB sync when S3 upload returns None."""
mock_fetch.return_value = SAMPLE_PRICING_ITEM
mock_upload.return_value = None
library_ingest_app.process_pricing_item_ingestion("item-abc-123")
mock_upload.assert_called_once()
mock_sync.assert_not_called()
# ---------------------------------------------------------------------------
# format_pricing_item_document
# ---------------------------------------------------------------------------
class TestFormatPricingItemDocument:
"""Test markdown formatting for pricing items."""
def test_format_includes_all_fields(self):
"""PR5: All pricing item fields appear in the formatted document."""
doc = library_ingest_app.format_pricing_item_document(SAMPLE_PRICING_ITEM)
assert "# Pricing Item: Standard HVAC Filter Replacement" in doc
assert "**Service Category:** HVAC" in doc
assert "**Unit:** each" in doc
assert "**Unit Price:** $85.50" in doc
assert "**Source:** PricingLibrary" in doc
assert "Replace standard 20x25 HVAC filter" in doc
assert "hvac, filter, replacement, merv-13" in doc
def test_format_handles_missing_unit_price(self):
"""PR5: None unitPrice renders as dash."""
item = {**SAMPLE_PRICING_ITEM, "unitPrice": None}
doc = library_ingest_app.format_pricing_item_document(item)
assert "**Unit Price:** -" in doc
def test_format_handles_empty_keywords(self):
"""PR5: Empty keywords list produces empty keywords section."""
item = {**SAMPLE_PRICING_ITEM, "keywords": []}
doc = library_ingest_app.format_pricing_item_document(item)
# Keywords section header exists, value is empty string
assert "## Keywords" in doc
def test_format_handles_string_keywords(self):
"""PR5: String keywords (non-list) are rendered as-is."""
item = {**SAMPLE_PRICING_ITEM, "keywords": "hvac, filters"}
doc = library_ingest_app.format_pricing_item_document(item)
assert "hvac, filters" in doc
# ---------------------------------------------------------------------------
# upload_pricing_item_to_library
# ---------------------------------------------------------------------------
class TestUploadPricingItemToLibrary:
"""Test S3 upload for pricing items."""
@patch.object(library_ingest_app, "s3")
def test_upload_uses_pricing_library_prefix(self, mock_s3):
"""PR5: S3 key uses pricing-library/ prefix, not proposals/."""
original_bucket = library_ingest_app.LIBRARY_BUCKET
library_ingest_app.LIBRARY_BUCKET = "test-library-bucket"
try:
result = library_ingest_app.upload_pricing_item_to_library(
SAMPLE_PRICING_ITEM, "# Test doc"
)
assert result == "pricing-library/hvac/item-abc-123.md"
mock_s3.put_object.assert_called_once()
call_kwargs = mock_s3.put_object.call_args[1]
assert call_kwargs["Key"] == "pricing-library/hvac/item-abc-123.md"
assert call_kwargs["Bucket"] == "test-library-bucket"
assert call_kwargs["ContentType"] == "text/markdown"
finally:
library_ingest_app.LIBRARY_BUCKET = original_bucket
def test_upload_returns_none_when_no_bucket(self):
"""PR5: Returns None when LIBRARY_BUCKET is not configured."""
original_bucket = library_ingest_app.LIBRARY_BUCKET
library_ingest_app.LIBRARY_BUCKET = ""
try:
result = library_ingest_app.upload_pricing_item_to_library(
SAMPLE_PRICING_ITEM, "# Test doc"
)
assert result is None
finally:
library_ingest_app.LIBRARY_BUCKET = original_bucket
@patch.object(library_ingest_app, "s3")
def test_upload_lowercases_category(self, mock_s3):
"""PR5: Category in S3 key is lowercased."""
original_bucket = library_ingest_app.LIBRARY_BUCKET
library_ingest_app.LIBRARY_BUCKET = "test-bucket"
item = {**SAMPLE_PRICING_ITEM, "serviceCategory": "Plumbing"}
try:
result = library_ingest_app.upload_pricing_item_to_library(
item, "# Test doc"
)
assert result is not None
assert "pricing-library/plumbing/" in result
finally:
library_ingest_app.LIBRARY_BUCKET = original_bucket

108
mobile/package-lock.json generated
View file

@ -17,7 +17,7 @@
"@react-navigation/native-stack": "^7.2.0",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.10",
"amazon-cognito-identity-js": "^6.3.0",
"amazon-cognito-identity-js": "^6.3.18",
"axios": "^1.16.0",
"react": "19.2.6",
"react-native": "^0.85.3",
@ -1306,13 +1306,6 @@
"picocolors": "^1.1.1"
}
},
"node_modules/@react-native-community/cli-config/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/@react-native-community/cli-config/node_modules/cosmiconfig": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz",
@ -1367,19 +1360,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@react-native-community/cli-config/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@react-native-community/cli-config/node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -1538,9 +1518,9 @@
}
},
"node_modules/@react-native-community/cli-server-api/node_modules/ws": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
"integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz",
"integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2404,16 +2384,16 @@
}
},
"node_modules/amazon-cognito-identity-js": {
"version": "6.3.16",
"resolved": "https://registry.npmjs.org/amazon-cognito-identity-js/-/amazon-cognito-identity-js-6.3.16.tgz",
"integrity": "sha512-HPGSBGD6Q36t99puWh0LnptxO/4icnk2kqIQ9cTJ2tFQo5NMUnWQIgtrTAk8nm+caqUbjDzXzG56GBjI2tS6jQ==",
"version": "6.3.18",
"resolved": "https://registry.npmjs.org/amazon-cognito-identity-js/-/amazon-cognito-identity-js-6.3.18.tgz",
"integrity": "sha512-tsFGh5sMWvcD0LzDqkfJLZeNAglswtFB2clep7E1xJZpo/djQgrDHvXTzgPmzZuA3oNF2UXynSuENEDNTS56Pg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-js": "1.2.2",
"buffer": "4.9.2",
"fast-base64-decode": "^1.0.0",
"isomorphic-unfetch": "^3.0.0",
"js-cookie": "^2.2.1"
"js-cookie": "^3.0.7"
}
},
"node_modules/amazon-cognito-identity-js/node_modules/buffer": {
@ -2499,6 +2479,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
@ -3732,16 +3719,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@ -3929,9 +3916,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -4366,9 +4353,9 @@
}
},
"node_modules/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"license": "MIT"
},
"node_modules/js-tokens": {
@ -4377,6 +4364,29 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsc-safe-url": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz",
@ -4482,14 +4492,14 @@
}
},
"node_modules/launch-editor": {
"version": "2.13.2",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz",
"integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==",
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.1.1",
"shell-quote": "^1.8.3"
"shell-quote": "^1.8.4"
}
},
"node_modules/leven": {
@ -6576,9 +6586,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -7353,9 +7363,9 @@
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"

View file

@ -18,7 +18,7 @@
"@react-navigation/native-stack": "^7.2.0",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.10",
"amazon-cognito-identity-js": "^6.3.0",
"amazon-cognito-identity-js": "^6.3.18",
"axios": "^1.16.0",
"react": "19.2.6",
"react-native": "^0.85.3",

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

@ -0,0 +1,29 @@
{
"name": "@proposal-system/api-contracts",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@proposal-system/api-contracts",
"version": "0.1.0",
"devDependencies": {
"typescript": "~5.7.0"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View file

@ -60,3 +60,63 @@ export interface CreateProposalRequest {
export interface UpdateLineItemsRequest {
lineItems: Omit<LineItem, 'id' | 'proposalId' | 'createdAt' | 'updatedAt'>[];
}
export interface Customer {
id: string;
name: string;
addresses: string[];
contactEmail: string | null;
createdAt: string;
}
export interface CreateCustomerRequest {
name: string;
addresses?: string[];
contactEmail?: string;
}
export interface UpdateCustomerRequest {
name?: string;
addresses?: string[];
contactEmail?: string | null;
}
export interface PagedResponse<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}
// PR5: Pricing Library types
export interface PricingLibraryItem {
id: string;
title: string;
description: string | null;
serviceCategory: ServiceCategory;
unit: string | null;
unitPrice: number | null;
keywords: string | null;
source: string;
createdAt: string;
updatedAt: string;
}
export interface CreatePricingLibraryItemRequest {
title: string;
description?: string;
serviceCategory: ServiceCategory;
unit?: string;
unitPrice?: number;
keywords?: string;
source?: string;
}
export interface UpdatePricingLibraryItemRequest {
title?: string;
description?: string;
serviceCategory?: ServiceCategory;
unit?: string;
unitPrice?: number;
keywords?: string;
}

View file

@ -12,6 +12,8 @@ import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage';
import ProposalListPage from './pages/proposals/list/ProposalListPage';
import AdminDashboard from './pages/admin/dashboard/AdminDashboard';
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
import CustomerManagementPage from './pages/admin/customers/CustomerManagementPage';
import PricingLibraryPage from './pages/admin/pricing-library/PricingLibraryPage';
export default function App() {
return (
@ -46,6 +48,8 @@ export default function App() {
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></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/customers" element={<RoleGuard roles={['Admin', 'SysAdmin']}><CustomerManagementPage /></RoleGuard>} />
<Route path="/admin/pricing-library" element={<RoleGuard roles={['Admin', 'SysAdmin']}><PricingLibraryPage /></RoleGuard>} />
<Route path="/admin/users" element={
<RoleGuard roles={['SysAdmin']}>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>

View file

@ -19,6 +19,8 @@ import AddCircleIcon from '@mui/icons-material/AddCircle';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import AssignmentIcon from '@mui/icons-material/Assignment';
import PeopleIcon from '@mui/icons-material/People';
import BusinessIcon from '@mui/icons-material/Business';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice';
import { selectUser } from '../app/slices/authSlice';
import type { RootState } from '../app/store';
@ -35,6 +37,8 @@ const dispatcherNav = [
const adminNav = [
{ label: 'Admin Queue', path: '/admin', icon: <AdminPanelSettingsIcon fontSize="small" /> },
{ label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon fontSize="small" /> },
{ label: 'Customers', path: '/admin/customers', icon: <BusinessIcon fontSize="small" /> },
{ label: 'Pricing Library', path: '/admin/pricing-library', icon: <MenuBookIcon fontSize="small" /> },
];
const sysadminNav = [

View file

@ -3,7 +3,9 @@ export const QUERY_KEYS = {
proposal: 'proposal',
proposalLineItems: 'proposalLineItems',
customers: 'customers',
customerList: 'customerList',
users: 'users',
dashboard: 'dashboard',
auditTrail: 'auditTrail',
pricingLibraryList: 'pricingLibraryList',
} as const;

View file

@ -4,18 +4,63 @@ export interface Customer {
id: string;
name: string;
addresses: string[];
/** Contact email for proposal delivery. Null when not set. */
contactEmail?: string | null;
createdAt: string;
}
export interface CustomerListParams {
page?: number;
pageSize?: number;
search?: string;
}
export interface PagedCustomerResponse {
items: Customer[];
totalCount: number;
page: number;
pageSize: number;
}
export interface CreateCustomerRequest {
name: string;
address: string;
contactEmail?: string;
}
export interface UpdateCustomerRequest {
name: string;
address: string;
contactEmail?: string;
}
export const customersApi = {
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */
search: async (query?: string): Promise<Customer[]> => {
const params = query ? `?query=${encodeURIComponent(query)}` : '';
const res = await apiClient.get(`/customers${params}`);
return res.data;
},
create: async (data: { name: string; addresses?: string[] }): Promise<Customer> => {
/** GET /api/customers/list?page=&pageSize= — paginated management list (admin/sysadmin). */
list: async (params: CustomerListParams = {}): Promise<PagedCustomerResponse> => {
const qs = new URLSearchParams();
if (params.page) qs.append('page', String(params.page));
if (params.pageSize) qs.append('pageSize', String(params.pageSize));
if (params.search) qs.append('search', params.search);
const res = await apiClient.get(`/customers/list?${qs.toString()}`);
return res.data;
},
/** POST /api/customers — create a new customer. */
create: async (data: CreateCustomerRequest): Promise<Customer> => {
const res = await apiClient.post('/customers', data);
return res.data;
},
/** PUT /api/customers/{id} — update an existing customer (admin/sysadmin). */
update: async (id: string, data: UpdateCustomerRequest): Promise<Customer> => {
const res = await apiClient.put(`/customers/${id}`, data);
return res.data;
},
};

View file

@ -0,0 +1,72 @@
import apiClient from './client';
export interface PricingLibraryItem {
id: string;
title: string;
description?: string | null;
serviceCategory: string;
unit?: string | null;
unitPrice?: number | null;
keywords?: string | null;
source: string;
createdAt: string;
updatedAt: string;
}
export interface PricingLibraryListParams {
page?: number;
pageSize?: number;
}
export interface PagedPricingLibraryResponse {
items: PricingLibraryItem[];
totalCount: number;
page: number;
pageSize: number;
}
export interface CreatePricingLibraryItemRequest {
title: string;
serviceCategory: string;
description?: string;
unit?: string;
unitPrice?: number | null;
keywords?: string;
}
export interface UpdatePricingLibraryItemRequest {
title: string;
serviceCategory: string;
description?: string;
unit?: string;
unitPrice?: number | null;
keywords?: string;
}
export const pricingLibraryApi = {
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */
list: async (params: PricingLibraryListParams = {}): Promise<PagedPricingLibraryResponse> => {
const qs = new URLSearchParams();
if (params.page) qs.append('page', String(params.page));
if (params.pageSize) qs.append('pageSize', String(params.pageSize));
const res = await apiClient.get(`/pricing-library/list?${qs.toString()}`);
return res.data;
},
/** POST /api/pricing-library — create a new pricing library item. */
create: async (data: CreatePricingLibraryItemRequest): Promise<PricingLibraryItem> => {
const res = await apiClient.post('/pricing-library', data);
return res.data;
},
/** PUT /api/pricing-library/{id} — update an existing pricing library item. */
update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise<PricingLibraryItem> => {
const res = await apiClient.put(`/pricing-library/${id}`, data);
return res.data;
},
/** DELETE /api/pricing-library/{id} — delete a pricing library item. */
remove: async (id: string): Promise<void> => {
await apiClient.delete(`/pricing-library/${id}`);
},
};

View file

@ -0,0 +1,375 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Box,
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
TextField,
InputAdornment,
Button,
Skeleton,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { toast } from 'react-toastify';
import {
customersApi,
type Customer,
type PagedCustomerResponse,
type CreateCustomerRequest,
type UpdateCustomerRequest,
} from '../../../lib/api/customers';
import { queryClient } from '../../../lib/queryClient';
import { QUERY_KEYS } from '../../../constants/queryKeys';
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
/** Simple email format check (client-side only). */
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
interface CustomerFormState {
name: string;
address: string;
contactEmail: string;
}
const EMPTY_FORM: CustomerFormState = { name: '', address: '', contactEmail: '' };
export default function CustomerManagementPage() {
useDocumentTitle('Customer Management');
// --- List state ---
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Debounce search input
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = (value: string) => {
setSearch(value);
if (debounceTimer) clearTimeout(debounceTimer);
const timer = setTimeout(() => {
setDebouncedSearch(value);
setPage(1);
}, DEBOUNCE_SEARCH);
setDebounceTimer(timer);
};
const {
data,
isLoading,
isError,
refetch,
} = useQuery<PagedCustomerResponse>({
queryKey: [QUERY_KEYS.customerList, debouncedSearch, page, pageSize],
queryFn: () => customersApi.list({ search: debouncedSearch || undefined, page, pageSize }),
});
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
// --- Dialog state ---
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof CustomerFormState, string>>>({});
const isEditing = editingCustomer !== null;
const openCreateDialog = () => {
setEditingCustomer(null);
setForm(EMPTY_FORM);
setFormErrors({});
setDialogOpen(true);
};
const openEditDialog = (customer: Customer) => {
setEditingCustomer(customer);
setForm({
name: customer.name,
address: customer.addresses?.[0] ?? '',
contactEmail: customer.contactEmail ?? '',
});
setFormErrors({});
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingCustomer(null);
setForm(EMPTY_FORM);
setFormErrors({});
};
const validateForm = (): boolean => {
const errors: Partial<Record<keyof CustomerFormState, string>> = {};
if (!form.name.trim()) errors.name = 'Name is required';
if (!form.address.trim()) errors.address = 'Address is required';
if (form.contactEmail.trim() && !isValidEmail(form.contactEmail.trim())) {
errors.contactEmail = 'Enter a valid email address';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleFieldChange = (field: keyof CustomerFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
// Clear field error on change
if (formErrors[field]) {
setFormErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
// --- Mutations ---
const createMutation = useMutation({
mutationFn: (data: CreateCustomerRequest) => customersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] });
closeDialog();
toast.success('Customer created');
},
onError: (error: Error) => {
toast.error(`Failed to create customer: ${error.message}`);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) =>
customersApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] });
closeDialog();
toast.success('Customer updated');
},
onError: (error: Error) => {
toast.error(`Failed to update customer: ${error.message}`);
},
});
const handleSubmit = () => {
if (!validateForm()) return;
const payload = {
name: form.name.trim(),
address: form.address.trim(),
...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}),
};
if (isEditing) {
updateMutation.mutate({ id: editingCustomer.id, data: payload });
} else {
createMutation.mutate(payload);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Customer Management</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
New Customer
</Button>
</Box>
<Card>
<CardContent sx={{ pb: 0 }}>
{/* Search bar */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<TextField
size="small"
placeholder="Search customers..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
sx={{ width: 300 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
</Box>
{/* Error state */}
{isError && (
<Alert
severity="error"
icon={<ErrorOutlineIcon />}
action={
<Button color="inherit" size="small" onClick={() => refetch()}>
Retry
</Button>
}
sx={{ mb: 2 }}
>
Failed to load customers. Please try again.
</Alert>
)}
{/* Table */}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Address</TableCell>
<TableCell>Contact Email</TableCell>
<TableCell sx={{ width: 60 }} />
</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((customer) => (
<TableRow key={customer.id} hover>
<TableCell sx={{ fontWeight: 600 }}>{customer.name}</TableCell>
<TableCell>{customer.addresses?.[0] ?? '-'}</TableCell>
<TableCell>
{customer.contactEmail ? (
customer.contactEmail
) : (
<Typography
component="span"
sx={{ color: '#94A3B8', fontStyle: 'italic', fontSize: 'inherit' }}
>
No email
</Typography>
)}
</TableCell>
<TableCell>
<Tooltip title="Edit customer" arrow>
<IconButton
size="small"
onClick={() => openEditDialog(customer)}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
{!isLoading && rows.length === 0 && !isError && (
<TableRow>
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
{debouncedSearch
? 'No customers match your search.'
: 'No customers yet. Click "New Customer" 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 Customer' : 'New Customer'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
error={!!formErrors.name}
helperText={formErrors.name}
size="small"
required
fullWidth
autoFocus
/>
<TextField
label="Address"
value={form.address}
onChange={(e) => handleFieldChange('address', e.target.value)}
error={!!formErrors.address}
helperText={formErrors.address}
size="small"
required
fullWidth
/>
<TextField
label="Contact Email"
type="email"
value={form.contactEmail}
onChange={(e) => handleFieldChange('contactEmail', e.target.value)}
error={!!formErrors.contactEmail}
helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'}
size="small"
fullWidth
/>
</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 Customer'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,479 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Box,
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
TextField,
Button,
Skeleton,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
Chip,
MenuItem,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { toast } from 'react-toastify';
import {
pricingLibraryApi,
type PricingLibraryItem,
type PagedPricingLibraryResponse,
type CreatePricingLibraryItemRequest,
type UpdatePricingLibraryItemRequest,
} from '../../../lib/api/pricingLibrary';
import { queryClient } from '../../../lib/queryClient';
import { QUERY_KEYS } from '../../../constants/queryKeys';
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const COL_COUNT = 6; // Title, Category, Unit, Unit Price, Source, Actions
/** Chip color mapping for service categories. */
const CATEGORY_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = {
HVAC: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' },
Plumbing: { bgcolor: '#DBEAFE', color: '#1E40AF', border: '1px solid #BFDBFE' },
Electrical: { bgcolor: '#FEF9C3', color: '#854D0E', border: '1px solid #FDE68A' },
General: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' },
Renovation: { bgcolor: '#F3E8FF', color: '#6B21A8', border: '1px solid #C084FC' },
Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' },
};
interface ItemFormState {
title: string;
serviceCategory: string;
unit: string;
unitPrice: string;
keywords: string;
description: string;
}
const EMPTY_FORM: ItemFormState = {
title: '',
serviceCategory: '',
unit: '',
unitPrice: '',
keywords: '',
description: '',
};
/** Format a number as $X.XX or return "—" for null/undefined. */
function formatPrice(value: number | null | undefined): string {
if (value == null) return '—';
return `$${value.toFixed(2)}`;
}
export default function PricingLibraryPage() {
useDocumentTitle('Pricing Library');
// --- List state ---
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const {
data,
isLoading,
isError,
refetch,
} = useQuery<PagedPricingLibraryResponse>({
queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize],
queryFn: () => pricingLibraryApi.list({ page, pageSize }),
});
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
// --- Create/Edit dialog state ---
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<PricingLibraryItem | null>(null);
const [form, setForm] = useState<ItemFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof ItemFormState, string>>>({});
const isEditing = editingItem !== null;
const openCreateDialog = () => {
setEditingItem(null);
setForm(EMPTY_FORM);
setFormErrors({});
setDialogOpen(true);
};
const openEditDialog = (item: PricingLibraryItem) => {
setEditingItem(item);
setForm({
title: item.title,
serviceCategory: item.serviceCategory,
unit: item.unit ?? '',
unitPrice: item.unitPrice != null ? String(item.unitPrice) : '',
keywords: item.keywords ?? '',
description: item.description ?? '',
});
setFormErrors({});
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingItem(null);
setForm(EMPTY_FORM);
setFormErrors({});
};
const validateForm = (): boolean => {
const errors: Partial<Record<keyof ItemFormState, string>> = {};
if (!form.title.trim()) errors.title = 'Title is required';
if (!form.serviceCategory) errors.serviceCategory = 'Service category is required';
if (form.unitPrice.trim() !== '') {
const parsed = parseFloat(form.unitPrice);
if (isNaN(parsed) || parsed < 0) {
errors.unitPrice = 'Must be a number >= 0';
}
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleFieldChange = (field: keyof ItemFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
if (formErrors[field]) {
setFormErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
// --- Delete confirm dialog state ---
const [deleteTarget, setDeleteTarget] = useState<PricingLibraryItem | null>(null);
const openDeleteDialog = (item: PricingLibraryItem) => {
setDeleteTarget(item);
};
const closeDeleteDialog = () => {
setDeleteTarget(null);
};
// --- Mutations ---
const createMutation = useMutation({
mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] });
closeDialog();
toast.success('Pricing item created');
},
onError: (error: Error) => {
toast.error(`Failed to create item: ${error.message}`);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) =>
pricingLibraryApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] });
closeDialog();
toast.success('Pricing item updated');
},
onError: (error: Error) => {
toast.error(`Failed to update item: ${error.message}`);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => pricingLibraryApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] });
closeDeleteDialog();
toast.success('Pricing item deleted');
},
onError: (error: Error) => {
toast.error(`Failed to delete item: ${error.message}`);
},
});
const handleSubmit = () => {
if (!validateForm()) return;
const payload = {
title: form.title.trim(),
serviceCategory: form.serviceCategory,
...(form.unit.trim() ? { unit: form.unit.trim() } : {}),
unitPrice: form.unitPrice.trim() !== '' ? parseFloat(form.unitPrice) : null,
...(form.keywords.trim() ? { keywords: form.keywords.trim() } : {}),
...(form.description.trim() ? { description: form.description.trim() } : {}),
};
if (isEditing) {
updateMutation.mutate({ id: editingItem.id, data: payload });
} else {
createMutation.mutate(payload);
}
};
const handleDeleteConfirm = () => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget.id);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Pricing Library</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
New Item
</Button>
</Box>
<Card>
<CardContent sx={{ pb: 0 }}>
{/* Error state */}
{isError && (
<Alert
severity="error"
icon={<ErrorOutlineIcon />}
action={
<Button color="inherit" size="small" onClick={() => refetch()}>
Retry
</Button>
}
sx={{ mb: 2 }}
>
Failed to load pricing library. Please try again.
</Alert>
)}
{/* Table */}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell>Category</TableCell>
<TableCell>Unit</TableCell>
<TableCell sx={{ textAlign: 'right' }}>Unit Price</TableCell>
<TableCell>Source</TableCell>
<TableCell sx={{ width: 100 }} />
</TableRow>
</TableHead>
<TableBody>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COL_COUNT }).map((_, j) => (
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
: rows.map((item) => {
const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' };
const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip;
return (
<TableRow key={item.id} hover>
<TableCell sx={{ fontWeight: 600 }}>{item.title}</TableCell>
<TableCell>
<Chip
label={item.serviceCategory}
size="small"
sx={{
bgcolor: chipStyle.bgcolor,
color: chipStyle.color,
border: chipStyle.border,
fontWeight: 600,
fontSize: '12px',
}}
/>
</TableCell>
<TableCell>{item.unit ?? '—'}</TableCell>
<TableCell sx={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{formatPrice(item.unitPrice)}
</TableCell>
<TableCell>{item.source}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Edit item" arrow>
<IconButton
size="small"
onClick={() => openEditDialog(item)}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete item" arrow>
<IconButton
size="small"
onClick={() => openDeleteDialog(item)}
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
);
})}
{!isLoading && rows.length === 0 && !isError && (
<TableRow>
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
No pricing items yet. Click &quot;New Item&quot; to add one.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page - 1}
onPageChange={(_, newPage) => setPage(newPage + 1)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
rowsPerPageOptions={[12, 24, 48]}
/>
</CardContent>
</Card>
{/* Create / Edit Dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{isEditing ? 'Edit Pricing Item' : 'New Pricing Item'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Title"
value={form.title}
onChange={(e) => handleFieldChange('title', e.target.value)}
error={!!formErrors.title}
helperText={formErrors.title}
size="small"
required
fullWidth
autoFocus
/>
<TextField
label="Service Category"
value={form.serviceCategory}
onChange={(e) => handleFieldChange('serviceCategory', e.target.value)}
error={!!formErrors.serviceCategory}
helperText={formErrors.serviceCategory}
size="small"
required
fullWidth
select
>
{SERVICE_CATEGORIES.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>
<TextField
label="Unit"
value={form.unit}
onChange={(e) => handleFieldChange('unit', e.target.value)}
helperText="e.g., each, hour, sq ft"
size="small"
fullWidth
/>
<TextField
label="Unit Price"
type="number"
value={form.unitPrice}
onChange={(e) => handleFieldChange('unitPrice', e.target.value)}
error={!!formErrors.unitPrice}
helperText={formErrors.unitPrice || 'Optional. Leave blank if price varies.'}
size="small"
fullWidth
slotProps={{
htmlInput: { min: 0, step: '0.01' },
}}
/>
<TextField
label="Keywords"
value={form.keywords}
onChange={(e) => handleFieldChange('keywords', e.target.value)}
helperText="Comma-separated keywords for AI matching."
size="small"
fullWidth
multiline
minRows={2}
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
size="small"
fullWidth
multiline
minRows={2}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirm Dialog */}
<Dialog open={deleteTarget !== null} onClose={closeDeleteDialog} maxWidth="xs" fullWidth>
<DialogTitle>Delete Pricing Item</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete <strong>{deleteTarget?.title}</strong>? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closeDeleteDialog} disabled={deleteMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
color="error"
onClick={handleDeleteConfirm}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -612,6 +612,10 @@ export default function AdminWorkspace() {
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}?
This action cannot be undone.
</Typography>
{/* PR4-B: surface delivery behavior — server emails PDF if customer has a contact email */}
<Alert severity="info" sx={{ mt: 2 }}>
The proposal PDF will be emailed to the customer's contact email on file, if one is set.
</Alert>
</DialogContent>
<DialogActions>
<Button onClick={() => setSendDialogOpen(false)}>Cancel</Button>

View file

@ -17,6 +17,7 @@ import SendIcon from '@mui/icons-material/Send';
import { toast } from 'react-toastify';
import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
import { sitesApi, type Site } from '../../../lib/api/sites';
import { customersApi } from '../../../lib/api/customers';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
import { queryClient } from '../../../lib/queryClient';
@ -33,7 +34,6 @@ interface ProposalFormState {
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['application/pdf'];
const CUSTOMERS = ['Amazon Services, LLC'] as const;
// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions
const MIN_SCOPE_LENGTH = 10;
@ -42,7 +42,7 @@ export default function ProposalFormPage() {
const [form, setForm] = useState<ProposalFormState>({
workOrderNumber: '',
customerName: CUSTOMERS[0],
customerName: '',
customerAddress: '',
scopeOfWork: '',
serviceCategory: '',
@ -63,9 +63,16 @@ export default function ProposalFormPage() {
const [customCategory, setCustomCategory] = useState('');
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// PR4: customer search state — freeSolo Autocomplete backed by customersApi.search
const [customerOptions, setCustomerOptions] = useState<string[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [customerInputValue, setCustomerInputValue] = useState('');
const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
};
}, []);
@ -104,6 +111,23 @@ export default function ProposalFormPage() {
}
}, []);
// PR4: search customers by name, debounced from the Autocomplete input
const searchCustomers = useCallback(async (query: string) => {
if (query.length < 2) {
setCustomerOptions([]);
return;
}
setCustomerLoading(true);
try {
const results = await customersApi.search(query);
setCustomerOptions(results.map((c) => c.name));
} catch {
setCustomerOptions([]);
} finally {
setCustomerLoading(false);
}
}, []);
const createMutation = useMutation({
mutationFn: async () => {
const notes = form.serviceCategory === 'Other' && customCategory.trim()
@ -203,21 +227,59 @@ export default function ProposalFormPage() {
onChange={(e) => handleChange('poNumber', e.target.value)}
/>
</Grid>
{/* PR4: freeSolo customer search — dispatchers can pick an existing
customer or type a new name (customerName is a free string). */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Customer"
required
fullWidth
select
value={form.customerName}
onChange={(e) => handleChange('customerName', e.target.value)}
>
{CUSTOMERS.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
<Autocomplete
freeSolo
options={customerOptions}
filterOptions={(x) => x}
loading={customerLoading}
inputValue={customerInputValue}
onInputChange={(_, value, reason) => {
if (reason === 'input') {
setCustomerInputValue(value);
handleChange('customerName', value);
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
customerDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
} else if (reason === 'clear') {
setCustomerInputValue('');
handleChange('customerName', '');
setCustomerOptions([]);
}
}}
onChange={(_, value) => {
const selected = typeof value === 'string' ? value : value ?? '';
handleChange('customerName', selected);
setCustomerInputValue(selected);
}}
noOptionsText={
customerLoading
? 'Searching...'
: customerInputValue.length >= 2
? `No customers found for "${customerInputValue}"`
: 'Type to search customers'
}
renderInput={(params) => (
<TextField
{...params}
label="Customer"
required
placeholder="Search or type a customer name..."
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{customerLoading && <CircularProgress size={20} />}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField